在我们的跨平台设计系统中,UI组件的代码审查(Code Review)一直是个分裂且低效的环节。Web端的组件审查流程相对成熟:每个Pull Request都会触发Storybook的自动构建和部署,审查者可以在一个独立的URL中交互式地验证组件的各种状态。然而,SwiftUI组件的审查却停留在原始时代。审查者必须拉取分支、启动Xcode、编译整个App、然后手动导航到某个预览画布,这个过程极其耗时且严重依赖本地环境的一致性。这导致iOS组件的UI审查往往被降级为对代码逻辑的静态分析,视觉回归几乎全凭感觉。
我们需要的不是对现有流程的修补,而是一个能将两者体验拉齐的统一平台。目标很明确:为每个包含UI改动的Pull Request生成一个唯一的、临时的可视化审查环境。这个环境必须能同时呈现Web组件的Storybook实例和SwiftUI组件的视觉快照。
最初的构想是构建一个复杂的动态服务,但考虑到审查环境的临时性,一个静态站点生成器是更务实的选择。Gatsby因此进入了视野。它强大的数据层和插件生态,使其不仅仅是一个博客工具。我们可以将其用作一个“数据聚合器”,在构建时拉取不同来源的构建产物(Storybook的静态文件、SwiftUI的预览截图),然后生成一个统一的审查门户。这套方案的核心挑战,在于如何可靠、自动化地从CI环境中“提取”出SwiftUI的视觉呈现。
核心工作流设计
整个流程依赖CI/CD管道进行串联,我们将使用GitHub Actions作为执行器。当开发者提交一个PR时,工作流被触发,并执行一系列并行与串行任务,最终将一个包含审查链接的评论发回PR。
graph TD
A[PR Opened/Updated] --> B{Trigger GitHub Action};
B --> C(Job: Build Web Storybook);
B --> D(Job: Capture SwiftUI Previews);
C --> E[Upload Storybook Artifact];
D --> F[Upload SwiftUI Snapshots Artifact];
E & F --> G{Job: Aggregate & Build Gatsby Site};
G --> H[Upload Gatsby Site Artifact];
H --> I{Job: Deploy & Comment};
I --> J[Deploy to Temporary URL];
J --> K[Post URL to PR Comment];
这个流程的关键在于Job: Capture SwiftUI Previews和Job: Aggregate & Build Gatsby Site这两个环节。前者是技术攻关的核心,后者则是将所有信息整合呈现的枢纽。
攻克SwiftUI预览自动化捕获
要在无UI的CI环境中捕获SwiftUI预览,唯一可靠的方式是利用XCTest框架。我们可以创建一个专门的UI测试目标(Target),它不执行任何业务逻辑断言,其唯一目的就是在模拟器中渲染我们的预览视图,并利用Xcode的测试能力进行截图。
1. 创建专用的测试目标
在Xcode项目中,我们创建一个名为PreviewSnapshotTests的新UI测试目标。这个目标将包含我们的截图逻辑。这里的关键是不要污染现有的单元测试或UI测试。
2. 编写快照测试用例
我们需要一个机制来发现所有标记为可预览的SwiftUI视图。一种务实的做法是定义一个协议,让所有需要生成快照的组件预览都遵循它。
// file: PreviewSnapshotting.swift
import SwiftUI
// 定义一个协议,用于识别所有需要被快照的预览
protocol SnapshotPreview {
// 提供一个包含所有需要测试的预览变体的数组
static var snapshotPreviews: [AnyView] { get }
// 组件名称,用于生成文件名
static var componentName: String { get }
}
// 示例:为一个按钮组件实现协议
struct MyButton_Previews: PreviewProvider, SnapshotPreview {
static var componentName: String {
return "MyButton"
}
static var previews: some View {
// 这是在Xcode中实时预览用的
VStack {
MyButton(label: "Primary", style: .primary)
MyButton(label: "Secondary", style: .secondary)
}
}
static var snapshotPreviews: [AnyView] {
// 这是专门为截图准备的,每个状态一个视图
return [
AnyView(MyButton(label: "Primary", style: .primary).previewLayout(.sizeThatFits)),
AnyView(MyButton(label: "Secondary", style: .secondary).previewLayout(.sizeThatFits)),
AnyView(MyButton(label: "Disabled", style: .primary, disabled: true).previewLayout(.sizeThatFits))
]
}
}
接下来,编写XCTestCase来消费这些预览。我们无法在编译时自动发现所有遵循SnapshotPreview的类型,因此需要手动维护一个列表。虽然听起来有些笨拙,但在真实项目中,这提供了对快照范围的精确控制,避免了意外的性能问题。
// file: PreviewSnapshotTests.swift
import XCTest
import SwiftUI
final class PreviewSnapshotTests: XCTestCase {
// 手动维护一个需要进行快照的预览类型列表
let previewables: [SnapshotPreview.Type] = [
MyButton_Previews.self,
// ... 添加其他组件的PreviewProvider
// UserAvatar_Previews.self,
// CardView_Previews.self,
]
// 这个测试方法会遍历所有预览并为每个状态截图
func test_generate_all_previews_snapshots() throws {
// 从环境变量中读取截图输出目录,这是CI脚本注入的
let snapshotsPath = ProcessInfo.processInfo.environment["SNAPSHOT_OUTPUT_PATH"] ?? "/tmp/snapshots"
// 确保目录存在
try? FileManager.default.createDirectory(atPath: snapshotsPath, withIntermediateDirectories: true)
// 遍历所有组件
for previewable in previewables {
let componentName = previewable.componentName
// 遍历组件的每个预览状态
for (index, preview) in previewable.snapshotPreviews.enumerated() {
let vc = UIHostingController(rootView: preview)
vc.view.frame = CGRect(x: 0, y: 0, width: 300, height: 200) // 可以根据需要调整
let app = XCUIApplication()
app.launch() // 启动App容器
// 获取屏幕截图
let screenshot = app.windows.firstMatch.screenshot()
// 定义截图文件名,例如 MyButton-0.png, MyButton-1.png
let fileName = "\(componentName)-\(index).png"
let filePath = (snapshotsPath as NSString).appendingPathComponent(fileName)
do {
try screenshot.pngRepresentation.write(to: URL(fileURLWithPath: filePath))
print("✅ Snapshot saved for \(componentName) at \(filePath)")
} catch {
XCTFail("❌ Failed to save snapshot for \(componentName): \(error)")
}
}
}
}
}
这里的坑在于,直接使用XCUIScreen.main.screenshot()会截取整个模拟器屏幕,包含状态栏等噪音。XCUIElement.screenshot()更精确,但需要将我们的UIHostingController的视图附加到当前的Window上,这在测试环境中操作起来很复杂。一个折衷且可靠的办法是,直接启动被测试App的空壳(XCUIApplication().launch()),然后获取windows.firstMatch的快照。虽然不是最理想的,但它在CI环境中工作得最稳定。
3. CI执行脚本
现在,我们需要一个shell脚本来驱动这个测试过程。
scripts/capture_swiftui_previews.sh
#!/bin/bash
set -e # 任何命令失败则立即退出
# --- 配置 ---
OUTPUT_DIR=${1:-"./snapshots"} # 第一个参数为输出目录,默认为 ./snapshots
WORKSPACE_PATH="./OurApp.xcworkspace"
SCHEME="OurApp" # 主App的Scheme
TEST_TARGET="PreviewSnapshotTests"
# 选择一个基础且启动快的模拟器
SIMULATOR_DEVICE="iPhone 14"
SIMULATOR_OS="iOS 16.2"
echo " BASH 脚本开始 "
# --- 清理与准备 ---
echo "🧹 Cleaning up old artifacts..."
rm -rf ${OUTPUT_DIR}
mkdir -p ${OUTPUT_DIR}
# 找到一个可用的模拟器
SIMULATOR_UDID=$(xcrun simctl list devices available | grep "${SIMULATOR_DEVICE}" | grep "${SIMULATOR_OS}" | head -n 1 | awk -F'[()]' '{print $2}')
if [ -z "$SIMULATOR_UDID" ]; then
echo "❌ Could not find a simulator matching ${SIMULATOR_DEVICE} (${SIMULATOR_OS})."
exit 1
fi
echo " booting up simulator ${SIMULATOR_UDID}..."
# 启动模拟器
xcrun simctl boot ${SIMULATOR_UDID}
# --- 执行测试 ---
echo "🚀 Running xcodebuild to capture snapshots..."
# 执行测试,并将输出目录作为环境变量传递给测试代码
# 使用 `| xcpretty` 可以让日志更清晰,CI环境上可移除以获得完整日志
xcodebuild test \
-workspace "${WORKSPACE_PATH}" \
-scheme "${SCHEME}" \
-destination "platform=iOS Simulator,id=${SIMULATOR_UDID}" \
-only-testing:${TEST_TARGET} \
SNAPSHOT_OUTPUT_PATH="${OUTPUT_DIR}"
# --- 生成元数据文件 ---
# 创建一个JSON文件,描述所有生成的快照,供Gatsby使用
echo "📝 Generating metadata file..."
METADATA_FILE="${OUTPUT_DIR}/manifest.json"
echo "{\"previews\": [" > ${METADATA_FILE}
FIRST=true
for f in ${OUTPUT_DIR}/*.png; do
if [ "$f" != "${OUTPUT_DIR}/*.png" ]; then # 检查是否有匹配的文件
if [ "$FIRST" = false ]; then
echo "," >> ${METADATA_FILE}
fi
FILENAME=$(basename "$f")
# 从文件名解析出组件名和状态索引
COMPONENT_NAME=$(echo ${FILENAME} | sed 's/-[0-9]*.png//')
STATE_INDEX=$(echo ${FILENAME} | sed 's/.*-\([0-9]*\).png/\1/')
# 将信息写入JSON
echo " {\"file\": \"${FILENAME}\", \"component\": \"${COMPONENT_NAME}\", \"stateIndex\": ${STATE_INDEX}}" >> ${METADATA_FILE}
FIRST=false
fi
done
echo "]}" >> ${METADATA_FILE}
echo "✅ SwiftUI preview capture complete. Manifest generated at ${METADATA_FILE}"
# --- 清理 ---
echo " shutting down simulator..."
xcrun simctl shutdown ${SIMULATOR_UDID}
这个脚本是整个流程的引擎。它负责查找并启动模拟器、执行xcodebuild命令、传递环境变量,并在最后生成一个manifest.json文件。这个JSON文件是后续Gatsby构建的数据源。
聚合内容的Gatsby站点
Gatsby站点将扮演中心枢纽的角色。它的任务是:
- 在构建时读取
manifest.json。 - 为每个组件创建一个审查页面。
- 在页面上展示所有SwiftUI快照。
- 嵌入或链接到已部署的Storybook实例。
1. Gatsby项目配置
首先,我们需要配置gatsby-node.js来动态创建页面。
gatsby-node.js
const path = require('path');
const fs = require('fs');
exports.createPages = async ({ graphql, actions }) => {
const { createPage } = actions;
const reviewTemplate = path.resolve('./src/templates/review.js');
// 在真实CI环境中,这个文件路径需要从artifact中获取
// 为了本地开发,我们可以指向一个本地的 manifest.json
const manifestPath = path.resolve('../snapshots/manifest.json');
if (!fs.existsSync(manifestPath)) {
console.warn(`
--------------------------------------------------
Warning: manifest.json not found at ${manifestPath}.
Skipping review page generation.
This is normal during initial Gatsby setup.
--------------------------------------------------
`);
return;
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
const previews = manifest.previews || [];
// 按组件名称对预览进行分组
const components = previews.reduce((acc, preview) => {
(acc[preview.component] = acc[preview.component] || []).push(preview);
return acc;
}, {});
// 为每个组件创建一个页面
Object.keys(components).forEach(componentName => {
createPage({
path: `/review/${componentName}`,
component: reviewTemplate,
context: {
componentName: componentName,
previews: components[componentName],
// Storybook的URL可以从环境变量或构建参数中获取
// 这里为了演示,我们硬编码一个规则
storybookUrl: process.env.STORYBOOK_URL || `http://localhost:6006/?path=/story/${componentName.toLowerCase()}`
},
});
});
// 创建一个索引页,列出所有可审查的组件
const indexTemplate = path.resolve('./src/templates/index.js');
createPage({
path: '/',
component: indexTemplate,
context: {
components: Object.keys(components)
}
});
};
2. 页面模板实现
src/templates/review.js
import React from 'react';
import { graphql } from 'gatsby';
const ReviewPage = ({ pageContext }) => {
const { componentName, previews, storybookUrl } = pageContext;
// 在CI构建时,图片资源会被Gatsby处理并放置在/static目录下
// 因此我们可以直接引用
const imageBasePath = '/';
return (
<div style={{ fontFamily: 'sans-serif', padding: '2rem' }}>
<header style={{ borderBottom: '1px solid #ccc', marginBottom: '2rem', paddingBottom: '1rem' }}>
<h1>{componentName}</h1>
<p>A unified review for SwiftUI and Web components.</p>
</header>
<main style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2rem' }}>
<section>
<h2>SwiftUI Previews</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', background: '#f5f5f5', padding: '1rem', borderRadius: '8px' }}>
{previews && previews.length > 0 ? (
previews.map(preview => (
<div key={preview.file}>
<strong>State: {preview.stateIndex}</strong>
<img
src={`${imageBasePath}${preview.file}`}
alt={`Preview for ${componentName} state ${preview.stateIndex}`}
style={{ maxWidth: '100%', border: '1px solid #ddd', marginTop: '0.5rem' }}
/>
</div>
))
) : (
<p>No SwiftUI previews found for this component.</p>
)}
</div>
</section>
<section>
<h2>Web (Storybook)</h2>
<p>
<a href={storybookUrl} target="_blank" rel="noopener noreferrer">
Open Interactive Storybook Instance
</a>
</p>
<iframe
src={storybookUrl}
title={`${componentName} Storybook`}
style={{ width: '100%', height: '600px', border: '1px solid #ccc', borderRadius: '8px' }}
/>
</section>
</main>
</div>
);
};
export default ReviewPage;
这里的重点是,img标签的src直接指向文件名。在Gatsby的构建流程中,我们会把所有截图文件复制到static目录下,这样它们就可以通过根路径被访问到。
完整的GitHub Actions工作流
最后,我们将所有步骤串联起来。
.github/workflows/visual-review.yml
name: Visual Component Review
on:
pull_request:
jobs:
build-storybook:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies and build
run: |
npm ci
npm run build-storybook
- name: Upload Storybook artifact
uses: actions/upload-artifact@v3
with:
name: storybook-build
path: storybook-static
capture-swiftui-previews:
runs-on: macos-12 # 必须使用macOS环境
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Select Xcode version
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '14.2'
- name: Run SwiftUI preview capture script
run: |
bash ./scripts/capture_swiftui_previews.sh ./ci-snapshots
- name: Upload snapshots artifact
uses: actions/upload-artifact@v3
with:
name: swiftui-snapshots
path: ./ci-snapshots
build-and-deploy-review-site:
needs: [build-storybook, capture-swiftui-previews]
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup Node.js for Gatsby
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Download Storybook artifact
uses: actions/download-artifact@v3
with:
name: storybook-build
path: ./storybook-build-artifact
- name: Download SwiftUI snapshots
uses: actions/download-artifact@v3
with:
name: swiftui-snapshots
path: ./swiftui-snapshots-artifact
- name: Setup review site
run: |
# 假设Gatsby站点在 `review-site` 目录下
cd review-site
npm ci
# 将截图和manifest复制到Gatsby可以访问的地方
# 在gatsby-node.js中需要配置好正确的相对路径
cp -r ../swiftui-snapshots-artifact/* ./static/
- name: Build Gatsby site
env:
# 在这里我们需要一个临时的URL来托管Storybook
# 假设我们有一个服务可以做到这一点,例如surge.sh或Netlify Drop
# 这里为了简化,我们假设Storybook会被部署在Gatsby站点的子目录
STORYBOOK_URL: /storybook
run: |
cd review-site
# 把storybook构建产物也复制到Gatsby的静态目录
cp -r ../storybook-build-artifact ./public/storybook
npm run build
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./review-site/public
# 使用一个与PR相关的唯一目录名
destination_dir: pr-reviews/${{ github.event.pull_request.number }}
user_name: 'github-actions[bot]'
user_email: 'github-actions[bot]@users.noreply.github.com'
- name: Post comment to PR
uses: actions/github-script@v6
with:
script: |
const prNumber = context.issue.number;
const repoOwner = context.repo.owner;
const repoName = context.repo.repo;
const pageUrl = `https://${repoOwner}.github.io/${repoName}/pr-reviews/${prNumber}/`;
await github.rest.issues.createComment({
owner: repoOwner,
repo: repoName,
issue_number: prNumber,
body: `🚀 **Visual Review Environment Ready!**\n\n[Click here to review UI changes](${pageUrl})`
});
这个GitHub Actions工作流是整个方案的粘合剂。一个常见的错误是忽略了artifact的下载和文件路径的正确处理。在build-and-deploy-review-site任务中,必须确保从artifact下载的文件被放置到Gatsby项目能够识别和处理的正确位置。
局限性与未来展望
这套方案极大地改善了我们的跨平台组件审查体验,但它并非完美。首先,SwiftUI的预览是静态图片,无法进行交互。一个可行的优化路径是使用simctl io recordVideo来录制短视频,展示组件的动画或交互过程,但这会显著增加CI的运行时间。
其次,每次PR都完整捕获所有组件的快照,对于大型项目而言效率低下。未来的迭代可以引入变更检测机制,只对PR中修改过的文件相关的组件进行快照,这需要更复杂的脚本逻辑来解析git diff并映射到组件。
最后,对macOS CI runner的依赖带来了成本问题。虽然对于iOS开发这是无法避免的,但优化脚本执行效率、减少不必要的构建步骤,是持续降低成本的关键。例如,可以构建一个包含所有依赖的Docker镜像(针对Web部分)或预热Xcode缓存,来缩短任务的准备时间。