做了一个录音转写应用,功能都写完了,上架的时候才发现真正的麻烦才开始。
应用是什么
TranscribeNote 是我写的一个 macOS 原生录音转写工具。核心功能是实时语音识别(ASR),用的是 macOS 26 的 SpeechAnalyzer + SpeechTranscriber,没有时间限制,支持 volatile/final results。
但光转写不够用。开了一个会,拿到一堆文字,还得自己看、自己整理。所以我在转写的基础上加了一整套 LLM 功能:
- 实时摘要:录音过程中每隔几分钟自动生成 chunk summary,录完后再合成 overall summary
- Action Items 提取:从转写文本中抽出待办事项、决策和跟进事项
- Chat Q&A:可以对着转写内容提问,比如”刚才谁提到了 deadline?”
- 自动标题生成:录完自动用 LLM 生成一个标题
为了灵活性,我做了一个 LLMEngine protocol,后面挂了 9 个 provider 实现——OpenAI、Anthropic、Ollama、DeepSeek、Moonshot AI、智谱、MiniMax、自定义 OpenAI 兼容 API(比如 LM Studio),以及 Apple Intelligence 端侧模型。用户在 Settings 里选 provider、填 API key、选模型,还可以给不同任务(live summary、overall summary、title、chat、action items)分配不同的 profile。
开发阶段一切顺利。本地跑 Ollama 做测试,切 OpenAI 跑一下看看效果,Apple Intelligence 作为 fallback。整个架构很灵活。
第一次上架:被拒
v1.0.0 提交 App Store 审核,被拒了。
第一个问题是 Guideline 2.3.8——App Store 里显示的名字是 “TranscribeNote”,但安装到 macOS 上显示的是 “notetaker”(项目最早的名字,忘了改)。这个好修,改一下 INFOPLIST_KEY_CFBundleDisplayName 就行。
但改完重新提交的时候,我开始认真想另一个问题:LLM 功能到底能不能过审?
最初的想法:全功能上架
我最初的想法很简单——“用户自带 key”模式。应用本身不提供 LLM 服务,只是一个客户端,用户自己去 OpenAI/Anthropic 注册账号、拿 API key、填进来。很多开源应用都是这么做的。
但仔细看了 App Store 审核指南之后,我意识到这里有坑。
Guideline 3.1.1:In-App Purchase
3.1.1 的核心条款是:如果应用内的功能需要通过外部服务解锁,必须用 IAP。“用户自带 key”这个模式处于灰色地带——你可以说”这不是应用解锁功能,是用户连接自己的服务”,但审核员也可以说”用户不填 key 就用不了摘要功能,这本质上是通过外部付费解锁”。
我看到过一些应用用这个模式过审了,也看到过被拒的。审核员的判断标准不一致,这次过了不代表下次更新还能过。对于个人开发者来说,被拒一次意味着一周的等待时间,风险太高。
Guideline 5:Legal(中国区)
中国区的情况更棘手。根据《生成式人工智能服务管理暂行办法》,提供生成式 AI 服务需要在网信办完成算法备案(LLM Filing)。截至 2025 年 3 月,已有约 350 个大模型完成备案,但那都是有公司主体的——DeepSeek、智谱、MiniMax 这些。
即使我的应用只是调用这些已备案平台的 API,作为面向公众提供 AI 服务的应用,可能也需要走独立备案流程。个人开发者要搞这个,先注册个公司再说吧。
而且 Apple Intelligence 在中国大陆也不可用。应用里已有的逻辑是通过 StoreKit.Storefront 检测区域(storefront.countryCode == "CHN"),Apple Intelligence 的 SystemLanguageModel.default.availability 在中国区直接返回不可用。
所以中国区的结论很明确:所有 LLM 功能都得砍掉,一个不留。
思路演变:从”一刀切”到”分层”
最开始我想的是最简单的方案:上 App Store 就把所有 LLM 功能全砍了,只留转写。一个编译标志搞定。
但转念一想,这太浪费了。全球版(除中国区)其实可以用 Apple Intelligence——完全端侧运行,不需要网络,不需要 API key,不涉及 IAP,不涉及第三方 AI 服务的法律问题。Apple 自己的技术,用在 Apple 的平台上,审核不会有任何争议。
虽然 Apple Intelligence 的效果确实一般(在我的评测中它是唯一表现不佳的模型),但有总比没有好。至少用户录完会能拿到一个基本的摘要,而不是对着一堆转写文字干瞪眼。
所以方案从”一刀切”变成了三层:
- 开发版:全功能,9 个 provider 随便选
- 全球 App Store 版:只有 Apple Intelligence 端侧模型,摘要功能保留
- 中国区 App Store 版:纯转写,所有 LLM 功能砍掉
实现:条件编译
用 Swift 的 #if 条件编译,通过 SWIFT_ACTIVE_COMPILATION_CONDITIONS 传入标志。不需要改 Xcode 项目配置,标志在构建命令里传。
| 构建 | 编译标志 | LLM Engine | Provider 列表 | 网络权限 |
|---|---|---|---|---|
| 开发/测试 | 无 | 按 config 选择 | 全部 9 个 | 需要 |
| 全球 App Store | APPSTORE | FoundationModelsEngine | 仅 Apple Intelligence | 不需要 |
| 中国区 App Store | CHINA_APPSTORE | NoopLLMEngine | 空 | 不需要 |
关键是找对切入点。整个 LLM 子系统有几十个文件——5 个引擎实现、SummarizerService、BackgroundSummaryService、ChatService、PromptBuilder、ActionItemParser……不可能每个文件都加 #if。
两个入口点搞定引擎层
所有 LLM 调用都经过 LLMEngineFactory.create(from:),在这一个点加条件编译,所有下游代码自动失效:
static func create(from config: LLMConfig, session: URLSession = llmSession) -> any LLMEngine { #if CHINA_APPSTORE return NoopLLMEngine() #elseif APPSTORE return FoundationModelsEngine() #else switch config.provider { case .foundationModels: FoundationModelsEngine() case .ollama: OllamaEngine(session: session) case .openAI: OpenAIEngine(session: session) case .anthropic: AnthropicEngine(session: session) case .deepSeek, .moonshot, .zhipu, .minimax, .custom: OpenAIEngine(session: session) } #endif}NoopLLMEngine 是项目里已有的空实现——generate() 返回空字符串,isAvailable() 返回 false。CHINA_APPSTORE 下所有 LLM 调用都会无声地返回空结果,不需要在调用方做任何处理。
UI 层的 provider 选择也类似,只需要控制 LLMProvider.availableProviders 这一个属性:
static var availableProviders: [LLMProvider] { #if CHINA_APPSTORE [] #elseif APPSTORE FoundationModelsEngine.isModelAvailable ? [.foundationModels] : [] #else if isChineseStorefront { allCases.filter(\.isAvailableInChina) } else { allCases } #endif}Settings 里的 provider Picker、WelcomeView 的模型选择页,都是从这个属性读数据。返回空数组,picker 就没东西可选。
UI 层:之前的架构决策救了我
这里要提一下之前做的一个设计决策,当时没想到会这么有用。
Settings 最早的结构是一个大 tab 把 LLM 和摘要设置混在一起。后来我做了两次重构:
- 拆分关注点:把 LLM 模型配置(provider 选择、API key、模型 profile、角色分配)从 Summarization tab 里拆出来,独立成
LLMAssignmentTab。Summarization tab 只保留摘要行为的设置(间隔、风格、最小长度、action items 开关)。 - Models 独立窗口:模型 profile 管理(
ModelsSettingsTab)原来也是 Settings 的一个 tab,后来移到了独立的Window("Models", id: "models")。profile editor 的 UI 比较复杂(sidebar + detail 布局,connection test,usage stats),塞在 Settings tab 里会把窗口撑得很大。
这两次重构的初衷纯粹是 UI 体验——关注点分离,窗口大小合理。但意外地为后来的条件编译铺好了路:
#if !APPSTORE && !CHINA_APPSTORELLMAssignmentTab() .tabItem { Label("LLM", systemImage: "brain") }#endif
#if !CHINA_APPSTORESummarizationSettingsTab() .tabItem { Label("Summarization", systemImage: "text.badge.star") }#endif因为 LLM 配置和摘要设置是完全独立的 tab,条件编译可以精确地控制每一个:APPSTORE 隐藏 LLM tab(用户不需要选 provider,只有端侧模型),但保留 Summarization tab(摘要功能还在,用户可以调间隔、风格)。CHINA_APPSTORE 两个都隐藏。
Models 窗口同理——独立 Window,APPSTORE 下整个不声明就行,不用在 Settings 内部做局部 #if。
如果当初 LLM 和摘要混在一个 tab 里,现在就得在 tab 内部用 #if 一段一段地切,代码会碎得多。好的架构拆分不只是让当前代码更清晰,还会在意想不到的地方降低未来的改动成本。
SessionDetailView 里隐藏 Generate Summary 菜单、Action Items 按钮、Chat 按钮。CHINA_APPSTORE 下直接显示转写内容,不显示 Summary/Transcript 的 tab 切换器:
#if CHINA_APPSTOREtranscriptTabContent#elsePicker(selection: $selectedTab) { Text("Summary").tag(0) Text("Transcript").tag(1)}// ...#endifOnboarding 也要分版本
Welcome 引导流程有 4 页,最后一页是”Set Up AI Model”让用户选 provider。APPSTORE 和 CHINA_APPSTORE 都不需要这页:
private var totalPages: Int { infoPages.count + modelConfigPageCount }private let modelConfigPageCount: Int = { #if APPSTORE || CHINA_APPSTORE return 0 #else return 1 #endif}()Welcome 页面的文案也做了条件编译。CHINA_APPSTORE 版本去掉了”AI-powered summaries”和”Chat with your transcripts”,只保留”Record and transcribe meetings in real-time”。APPSTORE 版本第三页把”adjust models in Settings”换成了”Summaries powered by Apple Intelligence on-device”。
别忘了 Entitlements
APPSTORE 和 CHINA_APPSTORE 都不需要 com.apple.security.network.client。一个用端侧模型,一个没有 LLM。创建了一个单独的 TranscribeNote.AppStore.entitlements,去掉了这个权限。
这不只是”少一个权限”的问题。审核员看到一个录音应用声明了网络权限,一定会问”为什么需要联网”。去掉它,一个审核问题直接消失。
之前踩过的坑
com.apple.security.personal-information.reminders这个权限之前也在 entitlements 里,结果提交时直接报错 90285——App Store 不支持这个 entitlement。在 PR #145 里删掉的。所以 entitlements 一定要仔细审,不需要的权限别留。
踩坑:增量构建 + 条件编译 = 灾难
第一次构建 CHINA_APPSTORE 版本,打开一看——Summary tab 还在,能看到之前生成的摘要。
原因是增量构建。xcodebuild build 只重新编译源码有改动的文件。SessionDetailView.swift 的源码没变(#if 分支的选择取决于编译标志,不是源码变化),所以编译器跳过了它,用的还是上一次(不带 CHINA_APPSTORE 标志时)编译的 .o 文件。
必须 clean build切换编译标志时一定要
clean build。不然你会得到一个混合了不同标志的二进制——有些文件编译时带了CHINA_APPSTORE,有些没带。这种 bug 极其诡异,因为部分功能正确隐藏了,部分没有。
为什么是条件编译,不是 Feature Flag
最初考虑过运行时 feature flag:
// ❌ 运行时检查if FeatureFlags.isLLMEnabled { // show LLM UI}放弃了,原因有三:
- 二进制内容:
#if编译出来的二进制完全不包含被排除的代码路径。审核员做静态分析时不会看到OpenAIEngine、AnthropicEngine的任何痕迹。运行时 flag 只是不执行,代码还在。 - Entitlements 是编译时绑定的:
network.client不能运行时开关。要么声明,要么不声明。 - 不需要动态切换:这不是 A/B 测试,不需要运行时改变行为。构建目标在编译时就确定了,用编译时方案最干净。
也考虑过拆 target(三个 Xcode target,各自不同的源码文件列表),但太重了。这个项目用的是 PBXFileSystemSynchronizedRootGroup,文件系统自动同步到项目里,拆 target 意味着要手动管理每个 target 包含哪些文件。条件编译几行代码搞定的事,没必要上架构。
构建命令
xcodebuild -scheme TranscribeNote -configuration Debug buildxcodebuild -scheme TranscribeNote -configuration Release \ SWIFT_ACTIVE_COMPILATION_CONDITIONS='APPSTORE $(inherited)' \ CODE_SIGN_ENTITLEMENTS='TranscribeNote/TranscribeNote.AppStore.entitlements' \ -archivePath /tmp/TranscribeNote-AppStore.xcarchive \ clean archivexcodebuild -scheme TranscribeNote -configuration Release \ SWIFT_ACTIVE_COMPILATION_CONDITIONS='CHINA_APPSTORE $(inherited)' \ CODE_SIGN_ENTITLEMENTS='TranscribeNote/TranscribeNote.AppStore.entitlements' \ clean build标志通过命令行传入,不需要改 pbxproj 里的 build settings。$(inherited) 保留 Release 配置自带的默认值(比如优化级别),只追加自定义标志。
回头看
整个方案最后的改动量出乎意料地小——7 个文件,120 行新增,17 行删除。核心思路就是找到 LLM 子系统的两个入口点(LLMEngineFactory.create() 和 LLMProvider.availableProviders),在那里加条件编译,然后在 UI 层做少量隐藏。
最花时间的不是写代码,是搞清楚三个版本各自需要什么、不需要什么。技术方案反而是最简单的部分。
一套代码库,三种产物,各自合规。