mcpField
I
ios-marketing-capture

ios-marketing-capture

github.com/parthjadhav营销与增长265512 次安装8 周前更新
代码仓库

关于 ios-marketing-capture

面向 SwiftUI iOS 应用的营销截图采集技能,可按多个语言环境生成可复现的全屏截图和独立组件渲染。它采用应用内捕获流程,并通过模拟器逐语言环境构建、启动和导出图像。

主要功能

  1. 01按语言环境采集全屏截图
  2. 02以确定性种子数据填充界面
  3. 03独立渲染卡片、组件和图表
  4. 04通过步骤协调导航、等待与截图
  5. 05构建并导出多语言截图

适用场景

  1. 01准备不同语言版本的应用商店截图
  2. 02制作透明背景的独立组件图像
  3. 03为营销页面提供一致的应用画面

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/parthjadhav/ios-marketing-capture --skill ios-marketing-capture

安装后在 Claude Code 中输入/ios-marketing-capture确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

LICENSE
CONTRIBUTING.md
README.md
.github/
FUNDING.yml
pull_request_template.md
.github/ISSUE_TEMPLATE/
feature_request.yml
bug_report.yml
config.yml
templates/
capture-marketing.sh.template
MarketingCapture.swift.template

详细介绍

iOS 营销素材采集

概述

为 SwiftUI iOS 应用自动采集可复现的营销截图,支持多种语言,并行生成两类输出:

  1. 全屏截图 — 涵盖所有与营销相关的屏幕,使用确定性的种子数据,并保留真实的状态栏和安全区域界面元素
  2. 元素截图 — 按任意比例单独渲染指定组件(卡片、小组件、图表),圆角内保留自然背景,外部透明

此技能负责采集步骤。如果用户还希望在截图周围合成 Apple 风格的营销页面(设备模型、标题、渐变),可将 app-store-screenshots 技能用作后处理步骤。

核心方案

采用应用内采集模式,而不是 XCUITest。这是一个明确的取舍:它不同于 Fastlane snapshot / XCUITest 的惯例,但几乎对所有实际项目都更合适。

选择应用内采集而不是 XCUITest 的原因:

  • 无需新增测试目标。 在现有 Xcode 项目中添加 UI 测试目标,需要小心地修改 pbxproj,过程很脆弱。许多项目没有任何测试目标,也没有 xcodegen;手动添加很容易出错。
  • 迭代更快。 UI 测试每次运行启动至少需要 30 秒。应用内采集只需重新启动已安装的二进制文件。
  • 无需 xcodebuild test。 整个流程只需运行一次 xcodebuild build,然后按语言环境运行 simctl launch。无需测试包开销。
  • 可访问真实应用状态。 可以直接调用 ViewModel、SwiftData、ImageRenderer 和 UIWindow.drawHierarchy。XCUITest 只能点击并读取辅助功能元素。
  • 元素渲染无论如何都需要在进程内运行。 对小组件视图或独立组件使用 ImageRenderer,必须在应用进程内运行,没有等效的 XCUITest 方案。

工作原理:

  1. 主应用目标中包含一个仅用于 DEBUG 的 MarketingCapture.swift 文件
  2. 使用 -MarketingCapture 1 启动时,应用会填充种子数据,然后由协调器遍历 CaptureStep 列表——每个步骤负责导航、等待界面稳定、截取快照并清理
  3. PNG 文件会写入应用沙盒的 Documents/marketing/<locale>/ 目录
  4. Shell 脚本只构建一次并安装,然后通过使用 -AppleLanguages (xx) -AppleLocale xx 重新启动应用来遍历各语言环境,最后通过 simctl get_app_container 拉取文件

流程

按顺序完成以下步骤。不要跳过。

步骤 1:收集需求

每次只向用户询问以下问题中的一个(不要一次性全部提问——每个答案都可能影响后续问题):

  1. 要采集的屏幕 — “你想采集哪些屏幕?请提供每个屏幕的导航路径或标签名称。” 获取明确的列表,不要只写“主要流程”。
  2. 独立元素 — “有没有希望单独渲染并使用透明背景的组件?(轮播卡片、小组件、主视觉图块、图表等)”
  3. 语言环境 — “需要哪些语言环境?(a) Localizable.xcstrings 中的全部语言环境,(b) 我指定的 App Store 子集,还是 (c) 由我提供明确列表?”如果选择 (a),请在 .xcstrings 文件中搜索语言环境代码:
    python3 -c "import json; d=json.load(open('<path>/Localizable.xcstrings')); langs=set(); [langs.update(v.get('localizations',{}).keys()) for v in d['strings'].values()]; print(sorted(langs))"
    
  4. 设备 — “要使用哪个模拟器?(推荐使用 6.1" iPhone 17,以支持 iOS 26 的设计特性)”通过 xcrun simctl list devices available 确认设备可用。
  5. 外观 — “仅浅色、仅深色,还是两者都要?”
  6. 种子数据 — “目前如何填充演示数据?(a) 全新安装时自动填充,(b) 有调试用的‘Load Demo Data’按钮,(c) 手动添加,(d) 目前还没有演示数据。”接着询问:“现有数据是否足够完整,能让你列出的每个屏幕都呈现适合营销展示的充实效果?请与用户一起检查。”
第 2 步:探索

编写任何代码之前,先充分探索代码库,以回答以下问题:

  • 项目是否使用 Xcode 同步文件夹组(Xcode 16+,PBXFileSystemSynchronizedRootGroup)?如果是,新增文件会自动加入目标,无需编辑 pbxproj。使用 grep -c PBXFileSystemSynchronized <proj>.xcodeproj/project.pbxproj 检查。
  • 根导航模式是什么?
    • TabView(selection:) —— 最常见。你需要了解:@State selectedTab 绑定、标签索引,以及哪些标签包含嵌套的 NavigationStack。
    • NavigationStack(带路由器的单一堆栈)—— 你需要了解:路径绑定或路由器对象,以及 NavigationLink(value:) / .navigationDestination 类型集合。
    • NavigationSplitView —— 你需要了解:侧边栏选择绑定、详情栏的导航状态。
    • 自定义协调器 / UIKit 宿主 —— 你需要了解:协调器的 navigate(to:) 方法或等效实现。
  • 深层链接如何路由?找到 onOpenURL 处理程序,以及将 URL 映射到导航状态的枚举 / switch。
  • 演示数据填充器定义在哪里?追踪从调试按钮(如果有)到实际写入 ModelContext 的函数的代码路径。如果没有数据填充器,请参阅下方的“创建演示数据填充器”。
  • 小组件是否位于单独的目标中?小组件视图文件和条目类型是否也在主应用目标中?(几乎可以肯定不在——如果要通过 ImageRenderer 渲染它们,就需要将它们加入目标。)
  • 应用是否使用 Live Activities / ActivityKit?如果是,请将其标记为已知问题(见下文)。
  • 应用是否使用 SwiftData + CloudKit 同步(cloudKitDatabase: .automatic)?如果是,请将其标记为已知问题。
  • 是否有视图需要以非默认状态进行捕获?(例如,倒计时进行中的计时器、部分填写的表单、具有特定值的图表。)如果是,每个视图都需要一个 static var 预置机制(见下方的“预置视图状态”)。
第 3 步:向用户介绍设计方案

编写代码之前,按以下结构总结计划,并征得明确批准后再继续:

  1. 架构(应用内捕获模式、单个文件、由 DEBUG 控制)
  2. 文件清单(将创建 / 修改的确切路径)
  3. 逐屏捕获计划(如何到达每个屏幕——标签索引、导航路径、工作表触发方式)
  4. 捕获顺序的理由(哪些屏幕必须先于其他屏幕捕获——见注意事项 #5)
  5. 元素渲染方案(使用哪些组件,以及如何包装它们)
  6. 输出布局(文件夹结构、命名约定)
  7. 与此项目相关的已知问题(从第 2 步中标记)
  8. 需要预置的状态(哪些视图,需要哪些静态变量)
第 4 步:实现

使用 templates/ 中的模板作为起点。它们是参考模式,不是可直接复制粘贴的脚手架——每个项目的导航、模型和视图都不同。模板展示了构建模块;你需要根据目标应用组合使用它们。

需要生成的关键文件:

  • <AppName>/Debug/MarketingCapture.swift —— 整个捕获系统,仅用于 DEBUG。包含:
    • MarketingCapture 枚举(启动参数解析、输出辅助方法、窗口快照、预置变量)
    • MarketingCaptureCoordinator 类(遍历 [CaptureStep] 并逐个截取快照)
    • MarketingElementHarness 枚举(使用 ImageRenderer 渲染卡片、小组件、图表)
  • <AppName>/ContentView.swift(或根视图所在位置)——用于填充数据并运行协调器的 DEBUG 钩子。
  • 任何需要预置状态的视图——由 DEBUG 控制的 .onAppear 钩子和 .onReceive 关闭监听器。
  • scripts/capture-marketing.sh ——构建、安装并按语言区域循环执行。
  • .gitignore ——添加 marketing/。
步骤 5:迭代验证

不要把脚本交给用户后就等着。自行在模拟器上运行脚本,并至少验证一个语言区域后再宣布完成。使用 Read 工具读取输出的 PNG,直观确认每个屏幕显示的内容符合预期。常见运行时问题列在下方的“已知注意事项”中。

发现问题时,修复后重新运行整个脚本(而不只是运行出错的语言区域——修复可能会导致之前验证过的语言区域回归),并再次进行视觉验证。

架构:基于步骤的捕获

协调器通过遍历 CaptureStep 值列表来驱动捕获。每个步骤都是独立的:它知道如何导航到对应屏幕、需要等待多久,以及之后如何清理。

struct CaptureStep {
    let name: String                        // output filename, e.g. "01-home"
    let navigate: @MainActor () -> Void     // put the app in the right state
    let settle: Duration                    // wait for animations/loads
    let cleanup: (@MainActor () -> Void)?   // tear down before next step
}

协调器是一个简单的循环:

for step in steps {
    step.navigate()
    try? await Task.sleep(for: step.settle)
    if let image = MarketingCapture.snapshotKeyWindow() {
        MarketingCapture.writePNG(image, name: step.name)
    }
    step.cleanup?()
    try? await Task.sleep(for: .milliseconds(400))  // cleanup animation
}
为不同的导航模式构建步骤

TabView 应用(最常见):

// Simple tab switch — just set the index
CaptureStep(name: "01-home", navigate: { setTab(0) }, settle: .milliseconds(1800), cleanup: nil)

// Tab + presented sheet
CaptureStep(
    name: "05-timer-setup",
    navigate: {
        setTab(3)
        pendingBrewRecipe = someRecipe
    },
    settle: .milliseconds(2000),
    cleanup: {
        NotificationCenter.default.post(name: MarketingCapture.dismissSheetNotification, object: nil)
        pendingBrewRecipe = nil
    }
)

NavigationStack + router 应用:

// Push a route onto the stack
CaptureStep(
    name: "02-detail",
    navigate: { router.push(.itemDetail(item)) },
    settle: .milliseconds(1800),
    cleanup: { router.popToRoot() }
)

NavigationSplitView 应用:

// Select sidebar item, then detail
CaptureStep(
    name: "03-detail",
    navigate: {
        sidebarSelection = .recipes
        detailSelection = recipes.first
    },
    settle: .milliseconds(1800),
    cleanup: { detailSelection = nil }
)
排序:堆栈规则

需要“干净”导航状态的屏幕,必须在会向同一堆栈推入页面的屏幕之前捕获。 子视图中的嵌套 NavigationPath / @State 无法由协调器弹出。因此:

Good:  Shelf (clean list) → Coffee Detail (pushes onto shelf's stack)
Bad:   Coffee Detail → Shelf (stack still has detail pushed)

如果两个屏幕共享一个 NavigationStack,请先捕获根视图。

准备视图状态

有些屏幕需要以特定的非默认状态进行捕获——例如倒计时中的计时器、显示特定数值的图表、填写了一半的表单。操作方式如下:

  1. 在 MarketingCapture 中为每个准备值添加一个 static var:

    /// Set by the coordinator before presenting the timer view.
    /// The view reads this in .onAppear to jump to a specific elapsed time.
    static var pendingElapsedSeconds: Int?
    
    /// Set to true to show the assessment overlay on the timer.
    static var pendingShowAssessment: Bool = false
    
  2. 在目标视图中添加一个受 DEBUG 控制的 .onAppear,用于读取准备值:

    .onAppear {
        #if DEBUG
        if MarketingCapture.isActive, let elapsed = MarketingCapture.pendingElapsedSeconds {
            phase = .active
            timerVM.elapsedTime = TimeInterval(elapsed)
            timerVM.start()
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { timerVM.pause() }
        }
        #endif
    }
    
  3. 在协调器中,在导航前设置该变量:

    CaptureStep(
        name: "06-timer-midway",
        navigate: {
            MarketingCapture.pendingElapsedSeconds = 75
            openTimerSheet(someRecipe)
        },
        settle: .milliseconds(2400),
        cleanup: {
            MarketingCapture.pendingElapsedSeconds = nil
            NotificationCenter.default.post(name: MarketingCapture.dismissSheetNotification, object: nil)
        }
    )
    

创建演示数据填充器

如果应用没有现成的演示数据机制,请创建一个。将其放在 <AppName>/Debug/DemoDataSeeder.swift 中,并用 #if DEBUG 包裹。

指南:

  • 填充足够多的数据,让每个捕获的屏幕都显示丰富内容。将屏幕列表与填充的数据进行核对。
  • 使用真实可信的内容:真实的地名、合理的数字、多样的状态(有些项目“库存偏低”,有些“新鲜”,有些带图片,有些不带)。
  • 如果应用使用 SwiftData,请直接写入 ModelContext。如果使用 Core Data,则使用托管对象上下文。如果使用 REST 后端,则通过本地缓存/存储层填充数据。
  • 确保填充操作可幂等执行——插入前检查数据是否已存在。存储会在模拟器重新启动后保留,按语言区域重复填充会导致 CloudKit 同步负担加重并引发崩溃。
  • 加入足够多样的数据,覆盖不同的 UI 状态:除非是营销屏幕,否则不应显示空状态。

最简结构:

#if DEBUG
enum DemoDataSeeder {
    static func seedIfEmpty(in context: ModelContext) {
        let existing = (try? context.fetchCount(FetchDescriptor<Item>())) ?? 0
        guard existing == 0 else { return }

        // Items with varied states
        let items = [
            Item(name: "...", status: .active, ...),
            Item(name: "...", status: .lowStock, ...),
            // ...enough to fill every screen
        ]
        items.forEach { context.insert($0) }
        try? context.save()
    }
}
#endif

元素渲染

元素通过 ImageRenderer 以 3x 比例渲染,圆角之外的区域保持透明。

卡片/列表行
@MainActor
static func renderCards(items: [Item], theme: AppTheme) {
    let cardWidth: CGFloat = 380

    for item in items {
        let card = ItemCard(item: item, theme: theme)
            .padding(.horizontal, 16)
            .padding(.vertical, 12)
            .frame(width: cardWidth)
            .background(theme.background)
            .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))

        let renderer = ImageRenderer(content: card)
        renderer.scale = 3
        renderer.isOpaque = false
        renderer.proposedSize = .init(width: cardWidth, height: nil)

        guard let image = renderer.uiImage else { continue }
        MarketingCapture.writePNG(image, name: "card-\(slugify(item.name))", subfolder: "elements")
    }
}
小组件

小组件视图需要特殊处理,因为它们通常在 WidgetKit 进程中运行,并依赖系统提供的内边距和背景。

@MainActor
static func renderWidget(
    name: String,
    size: CGSize,
    cornerRadius: CGFloat? = nil,
    @ViewBuilder content: () -> some View
) {
    let isAccessory = size.height <= 80
    let radius = cornerRadius ?? (isAccessory ? 8 : 22)
    let contentPadding: CGFloat = isAccessory ? 0 : 16

    let view = content()
        .padding(contentPadding)
        .frame(width: size.width, height: size.height)
        .background(theme.background)
        .clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
        .environment(\.colorScheme, .light)

    let renderer = ImageRenderer(content: view)
    renderer.scale = 3
    renderer.isOpaque = false
    renderer.proposedSize = .init(width: size.width, height: size.height)

    guard let image = renderer.uiImage else { return }
    MarketingCapture.writePNG(image, name: name, subfolder: "elements")
}

// Standard iPhone widget sizes (points, iPhone 14-17 size class)
enum WidgetSize {
    static let small  = CGSize(width: 170, height: 170)
    static let medium = CGSize(width: 364, height: 170)
    static let large  = CGSize(width: 364, height: 382)
    static let accessoryCircular    = CGSize(width: 76, height: 76)
    static let accessoryRectangular = CGSize(width: 172, height: 76)
    static let accessoryInline      = CGSize(width: 257, height: 26)
}

// Usage:
renderWidget(name: "widget-pulse-small", size: WidgetSize.small) {
    PulseSmallView(entry: PulseEntry(
        date: Date(),
        count: 2,
        streak: 5,
        lastItemName: "Morning Routine"
    ))
}
图表/独立视图

任何 SwiftUI 视图都可以渲染为元素。按相同方式封装——指定明确的尺寸、背景和圆角裁剪:

@MainActor
static func renderChart() {
    let chart = MyChartView(values: ChartData.sample)
        .frame(width: 420, height: 420)
        .background(theme.background)
        .clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))

    let renderer = ImageRenderer(content: chart)
    renderer.scale = 3
    renderer.isOpaque = false
    renderer.proposedSize = .init(width: 420, height: 420)

    guard let image = renderer.uiImage else { return }
    MarketingCapture.writePNG(image, name: "chart-overview", subfolder: "elements")
}

已知注意事项

这些都是曾在真实项目中造成影响的实际 bug。请务必认真对待。

1. 实时活动会在应用启动后继续存在

ActivityKit 实时活动在进程终止后仍会持续存在。如果应用在捕获期间启动了实时活动(例如通过计时器的 start()),那么下一个语言区域重新启动应用时就会继承该活动。如果此时重新填充数据时删除了过期实时活动所引用的模型,就会触发 SwiftData 持久化属性断言。

修复方法:在营销捕获代码块的最开头调用 <ActivityManager>.shared.endImmediately(),先于任何数据操作。此外,在捕获模式下,于视图的 onDisappear 中调用 timerVM.stop()(或其他能正确结束实时活动的方法)。

2. 不要在每种语言区域下都重新播种

按语言区域为 SwiftData + CloudKit 播种会导致同步频繁变动和崩溃。SwiftData 存储会在重新启动后保留数据——这些数据是与语言区域无关的演示内容,因此只需在首次运行时播种一次,后续运行时跳过:

contentVM.fetchItems()
if contentVM.allItems.isEmpty {
    DemoDataSeeder.seedIfEmpty(in: modelContext)
    contentVM.fetchItems()
}
3. 在播种前完成设置的 ViewModel 会持有过期快照

如果根视图的 onAppear 在营销内容播种前调用 someVM.setup(modelContext:),VM 就会持有空存储的快照。播种后,对每个需要使用数据的 VM 调用 someVM.refresh()(或等效的 fetch 方法)。

4. 将触发绑定设为 nil 并不会关闭工作表

如果父视图通过 .fullScreenCover(item: $request) 展示内容,而 request 由内部的 @State 驱动,那么设置 触发器 绑定(例如 pendingItem = nil)不会对封面视图产生任何影响。封面视图仍会显示,下一张截图就会捕获它,而不是你导航到的屏幕。

解决方法:通过 NotificationCenter 发送关闭信号,并让展示的视图监听该信号:

// MarketingCapture.swift
static let dismissSheetNotification = Notification.Name("MarketingCapture.dismissSheet")

// In presented view body
.onReceive(NotificationCenter.default.publisher(for: MarketingCapture.dismissSheetNotification)) { _ in
    dismiss()
}

然后在步骤的 cleanup 中发送通知,并至少留出 900ms,等待封面视图的动画完成,再开始下一步。

5. 无法从外部弹出 NavigationPath

如果子视图持有 @State private var navigationPath = NavigationPath(),并且深度链接向其中推入页面,协调器就无法访问它并弹出页面。解决方案:重新排列捕获顺序,将会向导航栈推入页面的屏幕,放到需要干净导航栈的屏幕之后。例如:先捕获 Shelf,再进入 Coffee Detail——不要反过来。

6. Widget 视图通常只存在于扩展目标中

如果用户的 widget 视图只位于 widget 扩展目标中,就无法在主应用目标的 MarketingCapture.swift 中引用它们。你需要:

  • (a) 将 widget 视图文件(以及它们的入口类型和所有共享辅助项)加入主应用目标。如果项目使用同步文件夹组,就需要编辑 PBXFileSystemSynchronizedBuildFileExceptionSet.membershipExceptions。**重要注意:membershipExceptions 是包含列表,而非排除列表。**列在其中的文件是目标的成员,而不是从目标中排除的文件。编辑前请仔细阅读并确认。
  • (b) 在捕获流程中跳过 widget 渲染,让用户手动完成。

你还需要从主应用目标中排除 <App>WidgetBundle.swift(它含有 @main,会与应用的 @main 冲突)。

7. ImageRenderer + ProgressView(value:total:) = 禁止符号

如果没有显式指定样式,通过 ImageRenderer 合成时,确定进度的 ProgressView 会渲染成带斜杠的红色圆圈。解决方法:在 ProgressView 上使用 .progressViewStyle(.linear)。这不会影响正常渲染,但能修复渲染问题。

8. .containerBackground(for: .widget) 在 widget 上下文之外不起作用

在应用中通过 ImageRenderer 渲染 widget 视图时,其 .containerBackground 不起作用——widget 的背景是透明的,内容以外的像素会直接裸露出来。你必须为 widget 渲染添加显式背景颜色,并裁剪为圆角矩形:

content()
    .padding(16)  // widget container normally provides this
    .frame(width: size.width, height: size.height)
    .background(theme.background)
    .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))

iPhone 主屏幕 widget 的圆角半径:约 22pt。锁定屏幕配件的圆角半径:约 8pt。

9. iOS 26 中已不再提供 iPhone 8 Plus

如果用户要求使用“6.5 英寸 iPhone”(旧版 App Store 尺寸),请注意,iOS 26 及更高版本的模拟器不包含 iPhone 8 Plus / iPhone 11 Pro Max。可选方案:(a) 通过 Xcode > Settings > Platforms 安装较旧的 iOS 运行时,或 (b) 对于 iOS 26 的设计功能,改用现代的 6.1 英寸机型,例如 iPhone 17。

10. 语言区域启动参数

每次 simctl launch 都传入 -AppleLanguages (xx) -AppleLocale xx。语言代码两侧的括号是必需的(它是 plist 数组字面量)。使用 Locale.current.language.languageCode?.identifier 进行文件夹命名——它比 Locale.current.identifier 更可靠,因为后者可能包含 en_US 这类地区后缀。

11. ImageRenderer 中的 SwiftUI 动画

ImageRenderer 只会捕获单帧,不会等待动画。如果组件包含 .onAppear 动画(例如图表绘制、数字递增),渲染时可能会捕获初始状态。可以在捕获模式下禁用动画,或在渲染前添加明确的延迟:

try? await Task.sleep(for: .milliseconds(500))  // let onAppear animations finish
let renderer = ImageRenderer(content: view)

输出布局

marketing/
    <locale>/           e.g. en, de, es, fr, ja
        01-home.png
        02-<screen>.png
        ...
        NN-<screen>.png
        elements/
            card-<name>.png
            widget-<family>-<size>.png
            chart-<name>.png

将 marketing/ 添加到 .gitignore。这些是输出文件,不是源文件。

验证清单

在确认捕获流程完成之前,请验证:

  • 所有语言区域都生成了 N 个文件(其中 N = 屏幕数 + 元素数)
  • 各语言区域的文件大小不同(这可以确认翻译确实已渲染——如果 en/settings.png 和 de/settings.png 的字节完全相同,说明语言区域切换未生效)
  • 查看主要语言区域的 2-3 个屏幕,并确认显示的内容符合预期
  • 查看至少一个其他语言区域的相同屏幕,并确认其中包含本地化字符串
  • 查看至少一个小组件渲染结果和一个卡片渲染结果,确认背景和圆角显示正常
  • 没有截图显示来自不同步骤的屏幕(最常见的错误——上一步中的工作表未关闭)

模板

  • templates/MarketingCapture.swift.template — 带步骤协调器的捕获文件框架。请参考本技能正文中的模式。
  • templates/capture-marketing.sh.template — Shell 脚本框架。为每个项目替换 bundle ID、scheme 名称和模拟器名称。

评论

支持 Markdown