2908 字
15 分钟
macOS 应用的多区域 App Store 编译策略:用条件编译拆分功能

做了一个录音转写应用,功能都写完了,上架的时候才发现真正的麻烦才开始。

应用是什么#

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 的效果确实一般(在我的评测中它是唯一表现不佳的模型),但有总比没有好。至少用户录完会能拿到一个基本的摘要,而不是对着一堆转写文字干瞪眼。

所以方案从”一刀切”变成了三层:

  1. 开发版:全功能,9 个 provider 随便选
  2. 全球 App Store 版:只有 Apple Intelligence 端侧模型,摘要功能保留
  3. 中国区 App Store 版:纯转写,所有 LLM 功能砍掉

实现:条件编译#

用 Swift 的 #if 条件编译,通过 SWIFT_ACTIVE_COMPILATION_CONDITIONS 传入标志。不需要改 Xcode 项目配置,标志在构建命令里传。

构建编译标志LLM EngineProvider 列表网络权限
开发/测试按 config 选择全部 9 个需要
全球 App StoreAPPSTOREFoundationModelsEngine仅 Apple Intelligence不需要
中国区 App StoreCHINA_APPSTORENoopLLMEngine不需要

关键是找对切入点。整个 LLM 子系统有几十个文件——5 个引擎实现、SummarizerServiceBackgroundSummaryServiceChatServicePromptBuilderActionItemParser……不可能每个文件都加 #if

两个入口点搞定引擎层#

所有 LLM 调用都经过 LLMEngineFactory.create(from:),在这一个点加条件编译,所有下游代码自动失效:

LLMEngineFactory.swift
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() 返回 falseCHINA_APPSTORE 下所有 LLM 调用都会无声地返回空结果,不需要在调用方做任何处理。

UI 层的 provider 选择也类似,只需要控制 LLMProvider.availableProviders 这一个属性:

LLMProvider.swift
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 和摘要设置混在一起。后来我做了两次重构:

  1. 拆分关注点:把 LLM 模型配置(provider 选择、API key、模型 profile、角色分配)从 Summarization tab 里拆出来,独立成 LLMAssignmentTab。Summarization tab 只保留摘要行为的设置(间隔、风格、最小长度、action items 开关)。
  2. Models 独立窗口:模型 profile 管理(ModelsSettingsTab)原来也是 Settings 的一个 tab,后来移到了独立的 Window("Models", id: "models")。profile editor 的 UI 比较复杂(sidebar + detail 布局,connection test,usage stats),塞在 Settings tab 里会把窗口撑得很大。

这两次重构的初衷纯粹是 UI 体验——关注点分离,窗口大小合理。但意外地为后来的条件编译铺好了路:

SettingsView.swift
#if !APPSTORE && !CHINA_APPSTORE
LLMAssignmentTab()
.tabItem { Label("LLM", systemImage: "brain") }
#endif
#if !CHINA_APPSTORE
SummarizationSettingsTab()
.tabItem { Label("Summarization", systemImage: "text.badge.star") }
#endif

因为 LLM 配置和摘要设置是完全独立的 tab,条件编译可以精确地控制每一个:APPSTORE 隐藏 LLM tab(用户不需要选 provider,只有端侧模型),但保留 Summarization tab(摘要功能还在,用户可以调间隔、风格)。CHINA_APPSTORE 两个都隐藏。

Models 窗口同理——独立 WindowAPPSTORE 下整个不声明就行,不用在 Settings 内部做局部 #if

如果当初 LLM 和摘要混在一个 tab 里,现在就得在 tab 内部用 #if 一段一段地切,代码会碎得多。好的架构拆分不只是让当前代码更清晰,还会在意想不到的地方降低未来的改动成本。

SessionDetailView 里隐藏 Generate Summary 菜单、Action Items 按钮、Chat 按钮。CHINA_APPSTORE 下直接显示转写内容,不显示 Summary/Transcript 的 tab 切换器:

SessionDetailView.swift
#if CHINA_APPSTORE
transcriptTabContent
#else
Picker(selection: $selectedTab) {
Text("Summary").tag(0)
Text("Transcript").tag(1)
}
// ...
#endif

Onboarding 也要分版本#

Welcome 引导流程有 4 页,最后一页是”Set Up AI Model”让用户选 provider。APPSTORECHINA_APPSTORE 都不需要这页:

WelcomeView.swift
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#

APPSTORECHINA_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
}

放弃了,原因有三:

  1. 二进制内容#if 编译出来的二进制完全不包含被排除的代码路径。审核员做静态分析时不会看到 OpenAIEngineAnthropicEngine 的任何痕迹。运行时 flag 只是不执行,代码还在。
  2. Entitlements 是编译时绑定的network.client 不能运行时开关。要么声明,要么不声明。
  3. 不需要动态切换:这不是 A/B 测试,不需要运行时改变行为。构建目标在编译时就确定了,用编译时方案最干净。

也考虑过拆 target(三个 Xcode target,各自不同的源码文件列表),但太重了。这个项目用的是 PBXFileSystemSynchronizedRootGroup,文件系统自动同步到项目里,拆 target 意味着要手动管理每个 target 包含哪些文件。条件编译几行代码搞定的事,没必要上架构。

构建命令#

开发版
xcodebuild -scheme TranscribeNote -configuration Debug build
全球 App Store(archive + 上传)
xcodebuild -scheme TranscribeNote -configuration Release \
SWIFT_ACTIVE_COMPILATION_CONDITIONS='APPSTORE $(inherited)' \
CODE_SIGN_ENTITLEMENTS='TranscribeNote/TranscribeNote.AppStore.entitlements' \
-archivePath /tmp/TranscribeNote-AppStore.xcarchive \
clean archive
中国区 App Store
xcodebuild -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 层做少量隐藏。

最花时间的不是写代码,是搞清楚三个版本各自需要什么、不需要什么。技术方案反而是最简单的部分。

一套代码库,三种产物,各自合规。

macOS 应用的多区域 App Store 编译策略:用条件编译拆分功能
https://blog.lishuyu.top/posts/macos应用多区域app-store编译策略/
作者
猫猫魔女
发布于
2026-04-02
许可协议
CC BY-NC-SA 4.0