设计实践

Uizard:设计改版后,开发和产品需要知道“哪些屏变了、变在哪里”

设计改版后,开发和产品需要知道“哪些屏变了、变在哪里”。Uizard 可以把屏幕导出成图片,但没有版本差异报告。 这篇做 exportaudit.py:先检查导出文件(数量上限、格式、倍率是否一致、是否透明背景),再对两次导出做逐屏视觉对比,并用红框标出改动。

BINBHO · 图文教程

设计改版后,开发和产品需要知道“哪些屏变了、变在哪里”。Uizard 可以把屏幕导出成图片,但没有版本差异报告。 这篇做 export_audit.py:先检查导出文件(数量上限、格式、倍率是否一致、是否透明背景),再对两次导出做逐屏视觉对比,并用红框标出改动。

先看清成品(box 上真实运行,用我合成的模拟导出图)

  • v1 → v2:Home 改动 4.65%,红框在按钮位置;Profile 改动 6.28%,位置在新增行(y≈800–912);Settings 是 0。
  • 混用 2x 和 3x 会收到警告;100 个文件会报错“需要拆分项目”。
  • 同一屏分别存成 q95 和 q60 的 JPG,修复后不再误报。pytest 7 项通过。

合规提醒:Uizard 没有公开 API,本文只用官方网页功能(Wireframe Scanner、Autodesigner、Handoff 导出),本地脚本只处理你自己的图片和文字,不抓取、不自动化操作 Uizard 网页,也不共享账号。Autodesigner 和组件代码导出需要 Pro 及以上套餐(以官方定价页为准,本文不列价格)。上传的草图、截图要是你有权使用的内容,不要上传别人受版权保护的界面去“复刻”商用。

运行说明:box 上没有 Uizard 账号,所以没有在 Uizard 里真正上传或生成过。本地脚本和测试都在 box 上真实运行过(Python 3.13、Pillow 13.0.0、numpy 2.5.4);测试用的草图照片和“导出图”是我自己用 Pillow 合成的,不是 Uizard 的真实素材。

做完之后你会得到什么?

成果 说明
export_audit.py audit 检查一个导出文件夹
export_audit.py diff 两版逐屏对比,输出 diff_*.png
make_mock_exports.py 生成模拟导出图
test_export_audit.py 7 项测试

需要准备哪些环境?

项目 版本
Python 3.9 以上
依赖 pip install pillow numpy pytest
Uizard 在 Handoff 模式中导出图片

官方导出规则

事实 来源
可以导出 JPG、PNG、SVG、PDF,倍率 1x~4x;PNG 可选透明背景 Exporting projects
组件可以导出 CSS 或 React 代码(需要 Pro 及以上),不能把整个项目导出成代码 Exporting projects、React 代码博客
导出 100 屏及以上不受支持,需要拆分项目 Troubleshooting exports

文件结构

64-uizard-C-src/
├── export_audit.py
├── make_mock_exports.py
└── test_export_audit.py

第 1 步:在 Uizard 里导出(手动)

  1. 打开项目,进入 Handoff 模式。
  2. 选择 PNG,两次导出都用同一倍率(例如 2x),分别放进 exports/v1/ 和 exports/v2/。
  3. 两次导出的屏幕名称要保持一致,脚本按文件名配对。

(本文没有实际导出;下面用模拟图代替。)

第 2 步:模拟导出图

"""生成两版“导出的屏幕图”(我自己画的模拟图,不是 Uizard 真实导出):v1、v2 各 3 屏,375 宽 × 2 倍 = 750 px。
v2 里 home 的按钮颜色改了、profile 多了一行,settings 不变。"""
import os
from PIL import Image, ImageDraw

def screen(name, btn=(60, 110, 230), extra_row=False, scale=2, alpha=False):
    W, H = 375 * scale, 812 * scale
    img = Image.new("RGBA" if alpha else "RGB", (W, H), (0, 0, 0, 0) if alpha else (250, 250, 252)); d = ImageDraw.Draw(img)
    d.rectangle((0, 0, W, 88 * scale), fill=(30, 30, 40))
    d.text((20 * scale, 40 * scale), name, fill=(255, 255, 255))
    for i in range(4 + extra_row):
        y = (120 + i * 70) * scale
        d.rounded_rectangle((20 * scale, y, W - 20 * scale, y + 54 * scale), 8 * scale, fill=(230, 232, 238))
    d.rounded_rectangle((40 * scale, 680 * scale, W - 40 * scale, 730 * scale), 25 * scale, fill=btn)
    return img

def build(root="mock_exports"):
    for v in ("v1", "v2"): os.makedirs(f"{root}/{v}", exist_ok=True)
    screen("Home").save(f"{root}/v1/Home.png"); screen("Profile").save(f"{root}/v1/Profile.png"); screen("Settings").save(f"{root}/v1/Settings.png")
    screen("Home", btn=(20, 160, 90)).save(f"{root}/v2/Home.png"); screen("Profile", extra_row=True).save(f"{root}/v2/Profile.png")
    screen("Settings").save(f"{root}/v2/Settings.png")

if __name__ == "__main__":
    build(); print("ok")

第 3 步:检查和对比工具

"""检查 Uizard 导出的屏幕图片,并比较两个版本。
依据官方帮助文档 Exporting projects / Troubleshooting exports:可导出 JPG、PNG、SVG、PDF,倍率 1x–4x,PNG 可选透明背景;
一次导出 100 屏及以上不受支持(需要拆分项目)。本工具只读本地文件,不调用 Uizard。"""
import argparse, json, os, sys
from PIL import Image, ImageChops, ImageDraw
import numpy as np

RASTER = {".png", ".jpg", ".jpeg"}
ALLOWED = RASTER | {".svg", ".pdf"}

def audit(folder, base_width=None):
    files = sorted(f for f in os.listdir(folder) if not f.startswith("."))
    rep = {"folder": folder, "count": len(files), "errors": [], "warnings": [], "screens": []}
    if len(files) >= 100:
        rep["errors"].append("100 屏及以上的导出不受支持:请把项目拆分后再导出")
    widths = set()
    for f in files:
        ext = os.path.splitext(f)[1].lower(); s = {"file": f, "ext": ext}
        if ext not in ALLOWED:
            rep["errors"].append(f"{f}: 不是 Uizard 的导出格式"); continue
        if ext in RASTER:
            with Image.open(os.path.join(folder, f)) as im:
                s["size"] = im.size; widths.add(im.width)
                if im.mode in ("RGBA", "LA") or "transparency" in im.info:
                    s["transparent"] = bool(np.asarray(im.convert("RGBA"))[..., 3].min() < 255)
                if base_width:
                    sc = im.width / base_width; s["scale"] = round(sc, 3)
                    if sc not in (1, 2, 3, 4):
                        rep["warnings"].append(f"{f}: 宽 {im.width} 不是 {base_width} 的 1–4 整数倍")
        rep["screens"].append(s)
    if len(widths) > 1:
        rep["warnings"].append(f"图片宽度不一致 {sorted(widths)}:可能混用了不同倍率或设备")
    return rep

def diff_pair(a, b, out_png, tol=16, cell=16, cell_min=0.5):
    """逐像素比较。JPG 的压缩噪点是零散分布的,所以只把“16px 格子里至少 50% 像素变化”的格子算作真实改动。"""
    A = Image.open(a).convert("RGB"); B = Image.open(b).convert("RGB")
    if A.size != B.size:
        return {"changed": None, "note": f"尺寸不同 {A.size} → {B.size},无法逐像素比较"}
    d = np.asarray(ImageChops.difference(A, B)).max(axis=2) > tol
    H, W = d.shape; gh, gw = H // cell, W // cell
    g = d[:gh * cell, :gw * cell].reshape(gh, cell, gw, cell).mean(axis=(1, 3)) >= cell_min
    pct = float(g.mean() * 100)
    if not g.any():
        return {"changed": 0.0, "raw_pixels_pct": round(float(d.mean() * 100), 3)}
    ys, xs = np.nonzero(g)
    box = (int(xs.min() * cell), int(ys.min() * cell), int((xs.max() + 1) * cell), int((ys.max() + 1) * cell))
    vis = B.copy(); dr = ImageDraw.Draw(vis)
    for y, x in zip(ys, xs):
        dr.rectangle((x * cell, y * cell, (x + 1) * cell - 1, (y + 1) * cell - 1), outline=(255, 0, 0), width=2)
    vis.save(out_png)
    return {"changed": round(pct, 2), "bbox": box, "image": out_png}

def compare(v1, v2, outdir):
    os.makedirs(outdir, exist_ok=True)
    s1 = {f for f in os.listdir(v1) if os.path.splitext(f)[1].lower() in RASTER}
    s2 = {f for f in os.listdir(v2) if os.path.splitext(f)[1].lower() in RASTER}
    res = {"added": sorted(s2 - s1), "removed": sorted(s1 - s2), "screens": {}}
    for f in sorted(s1 & s2):
        res["screens"][f] = diff_pair(os.path.join(v1, f), os.path.join(v2, f), os.path.join(outdir, "diff_" + os.path.splitext(f)[0] + ".png"))
    return res

if __name__ == "__main__":
    ap = argparse.ArgumentParser(); sub = ap.add_subparsers(dest="cmd", required=True)
    a1 = sub.add_parser("audit"); a1.add_argument("folder"); a1.add_argument("--base-width", type=int)
    a2 = sub.add_parser("diff"); a2.add_argument("v1"); a2.add_argument("v2"); a2.add_argument("--out", default="diff_out")
    a = ap.parse_args()
    r = audit(a.folder, a.base_width) if a.cmd == "audit" else compare(a.v1, a.v2, a.out)
    print(json.dumps(r, ensure_ascii=False, indent=2))
    sys.exit(1 if r.get("errors") else 0)

第 4 步:测试

import os, pytest
import export_audit as E, make_mock_exports as M

@pytest.fixture
def ex(tmp_path):
    M.build(str(tmp_path / "m")); return tmp_path / "m"

def test_audit_ok(ex):
    r = E.audit(str(ex / "v1"), 375)
    assert r["errors"] == [] and r["warnings"] == [] and {s["scale"] for s in r["screens"]} == {2.0}

def test_mixed_scale_and_transparency(tmp_path):
    M.screen("A", scale=2).save(tmp_path / "A.png"); M.screen("B", scale=3).save(tmp_path / "B.png")
    M.screen("C", scale=2, alpha=True).save(tmp_path / "C.png")
    r = E.audit(str(tmp_path), 375)
    assert any("宽度不一致" in w for w in r["warnings"]) and r["screens"][2]["transparent"] is True

def test_100_screens(tmp_path):
    for i in range(100): (tmp_path / f"s{i:03}.svg").write_text("<svg/>")
    assert E.audit(str(tmp_path))["errors"]
    os.remove(tmp_path / "s000.svg"); assert not E.audit(str(tmp_path))["errors"]

def test_bad_format(tmp_path):
    (tmp_path / "x.gif").write_bytes(b"GIF89a"); assert E.audit(str(tmp_path))["errors"]

def test_diff(ex, tmp_path):
    r = E.compare(str(ex / "v1"), str(ex / "v2"), str(tmp_path / "d"))
    s = r["screens"]
    assert s["Settings.png"]["changed"] == 0 and s["Home.png"]["changed"] > 1
    assert s["Profile.png"]["bbox"][1] >= 800        # 新增的那一行在 y≈840

def test_jpeg_noise_ignored(tmp_path):
    # 调试时发现:同一屏分别存为 q95 和 q60 的 JPG,按像素比较有 95% 不同,旧的 5% 格子阈值仍然报告 0.45% 改动
    M.screen("Home").save(tmp_path / "a.jpg", quality=95); M.screen("Home").save(tmp_path / "b.jpg", quality=60)
    assert E.diff_pair(str(tmp_path / "a.jpg"), str(tmp_path / "b.jpg"), str(tmp_path / "d.png"))["changed"] == 0

def test_size_mismatch(tmp_path):
    M.screen("A", scale=1).save(tmp_path / "a.png"); M.screen("A", scale=2).save(tmp_path / "b.png")
    assert E.diff_pair(str(tmp_path / "a.png"), str(tmp_path / "b.png"), "x")["changed"] is None

第 5 步:运行

$ python make_mock_exports.py
ok
$ python export_audit.py audit mock_exports/v2 --base-width 375
{"count": 3, "errors": [], "warnings": [], "screens": [{"file": "Home.png", "size": [750, 1624], "scale": 2.0}, ...]}
$ python export_audit.py diff mock_exports/v1 mock_exports/v2 --out diff_out
{"added": [], "removed": [],
 "screens": {"Home.png": {"changed": 4.65, "bbox": [80, 1360, 672, 1456], "image": "diff_out/diff_Home.png"},
             "Profile.png": {"changed": 6.28, "bbox": [32, 800, 704, 912], ...},
             "Settings.png": {"changed": 0.0, "raw_pixels_pct": 0.0}}}
$ python -m pytest -q
7 passed

怎么检查结果?

  • 打开 diff_out/diff_Home.png:红色小格应该只出现在按钮上。
  • 没有改动的屏 changed 为 0。
  • added、removed 中列出新增或删除的屏。

调试示例

JPG 压缩噪点被当成改动。 我把同一屏分别存成质量 95 和 60 的 JPG 做对比:

  • 按像素比较(容差 0):95.16% 不同;
  • 容差 16:仍有 0.09%,而且包围框几乎覆盖整屏,完全没有参考价值。

第一次修复:把图切成 16px 的格子,格子里至少 5% 像素变化才算改动。结果仍然报 0.45%,因为 JPG 的振铃噪点集中在形状边缘,边缘格子很容易超过 5%。

最终改为 50%:噪点降为 0,真实改动(按钮整块换色、新增一行)仍然能检出。代价是只改了 1px 细线这类改动可能漏掉,所以建议用 PNG 导出做对比。

常见问题

现象 原因 处理
尺寸不同,无法逐像素比较 两次导出倍率不同或屏幕尺寸变了 用同一倍率重新导出
宽度不一致警告 混用了倍率或设备 统一倍率
100 屏报错 超出官方导出限制 拆分项目后分批导出
细小文字改动没检出 50% 格子阈值的代价 用 PNG,必要时调小 cell_min
屏幕被当成新增或删除 改了屏幕名称 保持名称一致

模板规则

  • 对比只用 PNG,并且使用同一倍率。
  • 屏幕名称就是对比的键,不要随意改名。
  • 报告只作为提示,最终以人工查看红框为准。

项目资源

用途 地址
Exporting projects https://support.uizard.io/en/articles/6380330-exporting-projects
Troubleshooting exports https://support.uizard.io/en/articles/7950885-troubleshooting-exports
React 组件代码导出(官方博客) https://uizard.io/blog/generate-react-code-for-any-component/

每次改版都导出一次、对比一次,评审时就能直接看到哪些地方变了。