不分叉代码,也不把禁用当作隔离
此前用 Property Wrapper 集中了颜色、法律链接和名称等地区差异,并约定:值放进配置,行为通过协议提供,不能分发的模块由构建成员关系排除。到 2021 年末,第三类问题成了主要工作。
两个视频编辑应用共享绝大部分代码与编辑引擎,但地区要求会影响隐私、审核与数据流向。有些能力在特定版本中必须完全不存在,不能只是隐藏入口。
复制仓库容易让修复与引擎逐渐分叉;运行时 if region 只能改变执行行为,不能证明二进制没有包含某个 SDK。我们将约束写成一句话:不能分发的能力,其代码和依赖都不得进入对应二进制;其他部分尽量共享。
从构建系统到单个方法,为每种差异选择能够容纳它的最粗边界。
第一层:独立的依赖环境
原来一个工程中有两个 Target,共用一套集成环境。虽然可以按 Target 排除源文件,但某些第三方 SDK 仍出现在工作区与依赖解析结果中,容易被误引用。
我们用两个 Workspace 明确地区依赖集合,Podfile 根据环境选择工程和依赖:
region = ENV.fetch('APP_REGION', 'domestic')
workspace "VideoEditor-#{region}.xcworkspace"
project "VideoEditor-#{region}.xcodeproj"
target "VideoEditor-#{region}" do
pod 'EditingEngine'
pod 'Edit', :subspecs => ['Core', region.capitalize]
pod 'Resource', :subspecs => ['Core', region.capitalize]
if region == 'domestic'
pod 'FaceTrackingKit'
pod 'DomesticSocialShare'
end
end
国际版本的依赖图中没有 FaceTrackingKit,误写 import 就会构建失败。更重要的是检查实际解析与链接产物,而不是把某种目录组织本身当作充分保证。
代价是分别安装依赖、维护 Derived Data 和 CI 构建。两边都必须持续验证。
第二层:Core 与地区 Subspec
大部分共享、局部文件不同的 Pod,分成共同 Core 和各自地区 Subspec:
Pod::Spec.new do |s|
s.name = 'Edit'
s.subspec 'Core' do |ss|
ss.source_files = 'Edit/Core/**/*.swift'
ss.dependency 'EditingEngine'
end
s.subspec 'Domestic' do |ss|
ss.source_files = 'Edit/Domestic/**/*.swift'
ss.dependency 'Edit/Core'
ss.dependency 'FaceTrackingKit'
end
s.subspec 'International' do |ss|
ss.source_files = 'Edit/International/**/*.swift'
ss.dependency 'Edit/Core'
end
end
两个版本仍然 import Edit,地区文件只进入对应构建。调用者不需要到处写 #if。
依赖方向必须从地区实现指向 Core。由于这些 Subspec 仍可能编进同一个 Swift 模块,CocoaPods 不会自动禁止 Core 引用地区类型,因此 CI 还需要规则检查。真正的模块边界能提供更强的编译期保障。
第三层:不同模块与可选能力,要分开处理
本来就不同的模块,不必强行共享
两个地区的社区 Feed 在内容规则、产品归属甚至渲染技术上都不同。给它们套一个几乎为空的 Core,只会制造“共享”的表象。更直接的做法是两个独立 Pod,由各自 Workspace 引入。
共享模块里的受限能力,要抽出并注入
编辑时间线、裁剪、文字与导出都相同,只有美颜人脸追踪依赖某地区不能分发的供应商 SDK。原来的效果面板直接 import:
// Edit/Core/EffectsPanelController.swift — before
import FaceTrackingKit
final class EffectsPanelController {
private let tracker = FaceTracker()
func applyBeauty(to frame: Frame) -> Frame {
let landmarks = tracker.detect(in: frame)
return beautyRenderer.render(frame, landmarks: landmarks)
}
}
面板属于 Core,这个依赖让另一地区无法独立编译。修复方式是由 Core 声明能力协议与领域类型,地区实现再依赖供应商:
// Edit/Core/FaceLandmarkProviding.swift
public protocol FaceLandmarkProviding {
func landmarks(in frame: Frame) -> [FaceLandmark]
}
// Edit/Core/EffectsPanelController.swift — after
final class EffectsPanelController {
private let landmarks: FaceLandmarkProviding?
init(landmarks: FaceLandmarkProviding?) {
self.landmarks = landmarks
}
var supportsBeauty: Bool { landmarks != nil }
func applyBeauty(to frame: Frame) -> Frame {
guard let provider = landmarks else { return frame }
return beautyRenderer.render(frame, landmarks: provider.landmarks(in: frame))
}
}
// Edit/Domestic/FaceTrackingKitProvider.swift
import FaceTrackingKit
struct FaceTrackingKitProvider: FaceLandmarkProviding {
private let tracker = FaceTracker()
func landmarks(in frame: Frame) -> [FaceLandmark] {
tracker.detect(in: frame).map(FaceLandmark.init)
}
}
各地区 Assembly 决定是否注册 provider;未提供能力时返回 nil:
// Edit/Domestic/DomesticEditAssembly.swift
final class DomesticEditAssembly: Assembly {
func assemble(container: Container) {
container.register(FaceLandmarkProviding.self) { _ in FaceTrackingKitProvider() }
}
}
// Shared composition, in Core
let provider = resolver.resolve(FaceLandmarkProviding.self) // nil internationally
let panel = EffectsPanelController(landmarks: provider)
Core 拥有协议,地区实现依赖 Core 与供应商 SDK;另一地区不包含供应商实现。
supportsBeauty 判断的是能力是否存在,不是地区身份。Core 不需要知道缺失原因。
真正耗时的是供应商类型已经进入多个公开接口。除了方法调用,还必须把这些类型改成 Core 自己拥有的 FaceLandmark 等领域词汇。边界不拥有类型,就不是真正的边界。
第四层:把配置当作数据
API 域名、应用 ID、显示名称、强调色、法律链接和分析端点,集中到每个地区的 AppConfig。它们由 Resource Pod 的地区实现提供,沿用类型化配置读取。
我们还约定域名只在地区配置中出现,业务代码不拼接含域名的字符串 URL。CI 检查用于捕捉共享代码里误粘贴另一地区端点的问题。
第五层:只差一个步骤,就抽一个协议方法
登录的大部分流程相同,仅第三方跳转与回调处理不同,没有必要复制整个模块:
// Account/Core
public protocol SocialSignInHandoff {
var providerName: String { get }
func beginHandoff(from presenter: UIViewController)
func handleCallback(_ url: URL) -> SignInResult?
}
// Account/Domestic
struct DomesticSocialHandoff: SocialSignInHandoff { /* domestic app scheme */ }
// Account/International
struct InternationalSocialHandoff: SocialSignInHandoff { /* international app scheme */ }
Core 依赖 SocialSignInHandoff,地区实现负责具体跳转。五层设计实际围绕两种机制展开:成员关系决定什么进入二进制,注入决定由谁提供行为。层次描述的是应用粒度。
数据生命周期也要映射到这些边界
代码隔离之外,还要逐项说明收集什么、在哪里处理、发往哪里。
读取剪贴板或访问照片属于采集能力,应由明确产品与权限策略决定;本地人脸计算与上传人脸图像是不同的数据处理路径,不能混为一个开关;地区网络地址属于配置边界;遥测字段则通过允许列表在序列化前过滤。
这些是本项目如何把经过确认的地区要求落实到工程边界的经验,不应把某个具体功能在某地区的取舍当作普遍法律结论。
得到了什么,又承担了什么
是否包含某个 SDK,可以用依赖解析结果、链接产物和 link map 回答。编辑与登录的大部分实现仍然共享,地区需求也有明确落点。
成本同样明确:双构建环境需要双 CI;Subspec 的依赖方向仍需额外检查;已经渗入公开类型的依赖,事后抽离会很慢。
缺失的 SDK、完全不同的 Feed、不同域名和一个不同登录步骤,是四种不同形状的问题。分别使用构建成员关系、模块划分、配置和注入,比把一切塞进运行时地区判断更容易维护。评审时最有用的问题是:这个差异,能否放到更清楚、更粗的一层?