利用Gatsby与CI自动化统一SwiftUI和Web组件的可视化审查流程


在我们的跨平台设计系统中,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 PreviewsJob: 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站点将扮演中心枢纽的角色。它的任务是:

  1. 在构建时读取manifest.json
  2. 为每个组件创建一个审查页面。
  3. 在页面上展示所有SwiftUI快照。
  4. 嵌入或链接到已部署的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缓存,来缩短任务的准备时间。


  目录