iPhone 应用开发
从将一个 SwiftUI 应用从空仓库完整交付到在实体手机上运行的签名构建过程中提炼出的约定。
假设上下文:小规模应用、免费的个人 Apple 账户、无 App Store 分发。付费开发者团队会改变以下几条规则——签名部分会标记哪些规则会变化。
架构默认值
- SwiftUI + SwiftData,本地优先。 一个小应用不应依赖可达的服务器。数据存储在设备上的 SwiftData 中;网络调用仅用于公共 API(https,无 ATS 例外)。如果应用最初是现有后端的瘦客户端,预期会出现“我们能去掉服务器吗?”的转向——将后端逻辑移植为纯 Swift 函数,操作 SwiftData 数组,而不是为一个手机保留一个服务器。
- SwiftData 建模:使用真实对象关系,而不是整型外键。对自然键使用
@Attribute(.unique)。SwiftData 的多对多数组不保持顺序——当顺序重要时,添加一个sortOrder字段和一个sortedX计算属性。日期使用Date,而不是字符串。枚举使用String原始值的Codable。 - 实时安装上的架构变更:添加模型或可选属性会在下次启动时静默地进行轻量级迁移;重命名、删除或类型变更如果没有
SchemaMigrationPlan会导致存储无法打开——部署的应用会在启动时崩溃,用户数据完好但被锁定。重新部署(相同的 bundle ID)永远不会擦除数据容器;只有删除应用才会。在用户积累真实数据之前,规划一个导出/备份路径。 - 视图直接与
@Query+modelContext通信。 除非某些东西确实需要缓存,否则不需要 Store / repository 层。纯逻辑(匹配、解析、导入)位于枚举命名空间中(enum Foo { static func … })——无需 UI 即可测试。 - 部署目标:当前主要版本减一,除非某个所需 API 另有要求。一个目标,
TARGETED_DEVICE_FAMILY: "1",竖屏,除非应用需要旋转。
项目生成——XcodeGen,绝不提交 .xcodeproj
ios/project.yml 是真实来源;.xcodeproj 包是生成的并被 gitignore(同时 gitignore ios/build/ 和生成的 Info.plist)。添加或删除文件后重新生成——xcodegen 会扫描源目录,因此编辑现有文件不需要任何操作。
- 从 GitHub 发布 zip 安装 xcodegen(
PREFIX=~/.local ./install.sh)——无需 Homebrew,可在锁定的 Mac 上工作。 - 单元测试目标需要
GENERATE_INFOPLIST_FILE: YES,否则测试 bundle 签名会失败。 - 在
project.yml中定义 scheme,设置gatherCoverageData: true并附加测试目标,否则xcodebuild test没有东西可运行。
开发循环——纯命令行,默认模拟器
每个 iOS 项目都包含这些 Makefile 目标。将模拟器名称、设备 ID 和团队 ID 参数化为 ?= 变量,这样就不会硬编码任何机器特定的内容:
| 目标 | 功能 |
|---|---|
ios-project | xcodegen 生成 + 在 Xcode 中打开 |
ios-sim | 为模拟器构建未签名应用,启动模拟器,安装,启动——默认循环;零签名,零密码提示 |
ios-sim-seed | ios-sim,然后使用 --seed-demo 重新启动 |
ios-test | 在模拟器中运行 xcodebuild test + 每个目标的 xccov 覆盖率报告 |
ios-deploy | 为插入的手机构建签名应用 + devicectl 安装 + 启动 |
ios-test 在每次运行时打印覆盖率——内部规则是 make test 始终显示数字,没有硬性阈值。
模拟器构建:CODE_SIGNING_ALLOWED=NO 对于 generic/platform=iOS 的编译检查没问题;模拟器目标根本不需要签名。记住模拟器和手机有不同的数据库。
总能带来回报的调试便利设施
- 示例数据种子器(
DemoData.swift,#if DEBUG):精选数据集,过期/日期字段相对于.now计算,幂等(重新运行不添加任何内容),通过三种方式暴露——设置中的“开发者”部分按钮、通过ProcessInfo.processInfo.arguments检查的--seed-demo启动参数以及ios-sim-seed目标。添加一个断言种子器能锻炼应用主要状态的测试,这样它就不会静默腐烂。 - 当列表可能超过几十项时,使用可搜索的 Picker 而不是滚轮 Picker(导入的数据集保证会达到这个规模)。
- 相机/扫描器功能:检查
DataScannerViewController.isSupported && .isAvailable并提供手动输入的后备方案——这样也能在模拟器中优雅降级。
测试
- 在模拟器中使用 XCTest;当从其他栈移植逻辑时,同时移植参考测试套件,这样语义能在跨语言时锁定。
- 共享辅助工具:
makeInMemoryContext(),构建一个设置了isStoredInMemoryOnly: true的 ModelContainer;@MainActor测试类使用setUp() async throws。 - 从结果 bundle 读取通过/失败:
xcrun xcresulttool get test-results summary --path build/test.xcresult。
签名与部署——陷阱(每个都花费了真金白银的时间)
- 团队 ID 不是
security find-identity中括号内的内容——那是证书自己的 ID。从配置文件中获取真实的团队 ID(security cms -D -i <profile>→application-identifier前缀)或 Xcode 的 Accounts 面板。错误的 ID 会导致误导性的“No Account for Team”错误。 -allowProvisioningUpdates无法从普通终端看到 Apple ID 会话。 在 Xcode GUI 中创建配置文件一次(Signing & Capabilities → 设置 Team → Try Again);之后,纯 CLI 构建可以与DEVELOPMENT_TEAM=<id> CODE_SIGN_STYLE=Automatic一起工作。- 首次设备舞蹈:在手机上启用开发者模式(设置 → 隐私与安全性;仅当 Xcode 与设备通信后才会出现切换开关;重启)→ 信任计算机 → 首次安装后,信任开发者证书(设置 → 通用 → VPN 与设备管理)。Xcode 的“your team has no devices”错误通常意味着在手机重启或锁定时进行了注册尝试——选择手机作为运行目标并重试。
- 每次构建都弹出钥匙串提示:在 codesign 钥匙串对话框上点击一次始终允许;每次构建的密码提示就会停止。
- 免费个人团队限制:安装会在 7 天后过期(重新部署以重新签名),最多 3 个侧载应用,无推送通知,无 App Groups。付费团队可以解除所有这四个限制——如果应用需要推送或 App Groups,那就是获得付费团队的触发条件。
- 部署/启动 CLI:
xcrun devicectl list devices,xcrun devicectl device install app --device <id> <path>.app,… device process launch --device <id> <bundle-id>。
新 Mac 引导(适合无头环境)
- 从 App Store 安装 Xcode(打开
macappstore://apps.apple.com/app/id497799835)。 sudo xcode-select -s /Applications/Xcode.app && sudo xcodebuild -license accept && sudo xcodebuild -runFirstLaunch——需要管理员权限;在标准账户上,先修复管理员组成员身份(su admin -c 'sudo dseditgroup -o edit -a <user> -t user admin')。xcodebuild -downloadPlatform iOS——自 Xcode 15 起,iOS SDK + 模拟器运行时是一个单独的几 GB 下载;如果没有它,构建会失败并提示“iOS X.Y is not installed”。- 在修复 xcode-select 之前,
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer可以作为每个命令的覆盖项使用。 - 从发布 zip 将 xcodegen 安装到
~/.local(见上文)。
现有服务器应用的配套客户端——同步层次
当 iPhone 应用加入一个已有服务器 + Web 应用的应用时(服务器保持为单一真实来源),选择手机用例允许的最便宜的层次。先问“手机是否需要脱离服务器网络工作?”和“捕获还是完整编辑?”:
- 瘦客户端——无本地存储;手机调用现有 API。当连接性得到保证时合适。
- 快照镜像——从完整的 JSON 拉取整体重建 SwiftData 缓存(单用户数据库很小)。无需合并 → 无需墓碑、无逐行时间戳、无冲突引擎;服务器端的删除/编辑免费到达。照片通过 URL 缓存延迟加载。
- 限定发件箱——离线创建仅限于已存在记录的子项(日志、笔记、针对已知父项的照片)。父项持有真实的服务器 ID,因此重放就是简单的 API 调用——整数主键模式不需要 UUID 迁移。同步事件 = 推送发件箱,然后拉取快照(自己的捕获会被确认返回)。显示 404 孤项(“N 项无法同步”),绝不静默丢弃。显示待处理标记 + 上次同步时间戳。仅当用户固定的服务器地址响应时自动同步——绝不探测外部网络。
- 完全双向同步(UUID、墓碑、delta 端点、LWW/CRDT)——仅在证据表明第 1-3 层不够用时使用;重构从服务器端开始(客户端可生成的 UUID、幂等 upsert、
changes?since=+ 墓碑),然后才编写任何 Swift 代码。
第 2 层和第 3 层可以很好地组合:用于读取的快照镜像加上用于捕获的限定发件箱,覆盖了“在家工作,随处捕获”的需求,而无需任何第 4 层的机制。
项目卫生
- 机器和设备特定信息(团队 ID、设备 UDID、模拟器名称)放在 Makefile 的
?=变量或项目自己的笔记中——绝不硬编码在脚本中,也绝不放进本技能。 - 为每个进行中的工作片段维护一个
plans/<topic>.md文档,以及一个顶级的CHANGELOG.md记录已发布的内容;一旦计划落地,将其移至plans/done/<topic>.md,并添加一个“Delivered in — ”头部。 - 每个计划片段的大小应确保它能以可在手机或模拟器上运行的东西结束。一个无法启动的 iOS 片段是无法审查的。