全部文章

一份代码、两个地区:五层隔离的设计

从 Workspace、依赖与 Subspec,到能力注入、配置和单个协议方法,为不同形状的地区差异选择合适的边界。

不分叉代码,也不把禁用当作隔离

此前用 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、不同域名和一个不同登录步骤,是四种不同形状的问题。分别使用构建成员关系、模块划分、配置和注入,比把一切塞进运行时地区判断更容易维护。评审时最有用的问题是:这个差异,能否放到更清楚、更粗的一层?