SDK 不是缩小版的 App
文档 SDK 看起来是一组页面,实际上却是运行在宿主生命周期里的应用系统。宿主提供身份、导航、共享网络、埋点与应用生命周期;SDK 负责首页、最近与共享文档、搜索、详情、编辑,以及缓存、离线、权限和协同状态。
切换账号会同时改变凭据、缓存分区、权限快照和协作订阅;离线恢复涉及操作重放、版本更新与冲突处理。真正困难的不是创建 view controller,而是三个问题:谁组装持续增长的对象图,如何替换不同环境的实现,以及对象应该活多久。
依赖问题如何慢慢出现
最初由页面创建 view model,再创建 repository,最后寻找网络和缓存,很容易写:
final class DocumentListViewModel {
private let repository = DocumentRepository(
remote: HTTPDocumentService.shared,
cache: SQLiteDocumentCache.shared
)
}
但构造逻辑与业务流程混在一起后,测试无法替换依赖,环境判断到处都是,账号切换后单例仍持有旧凭据,初始化顺序也散落在各个页面。
风险不在于初始化参数长,而在于对象自己决定依赖从哪里来。类型签名不再描述它真正需要的运行条件。
为什么不用全局单例或 Service Locator
全局单例把生命周期固定到进程,对频繁切换账号和数据域的 SDK 往往太长。全局容器虽然允许到处 resolve,却把依赖从初始化方法中隐藏起来,缺失注册只能等某条路径运行时才暴露。
我们需要的是 Composition Root(组装入口):在边界描述对象图,业务对象仍然使用初始化注入,容器不随业务对象向内传递。
手动注入本来是默认选择,模块少、关系稳定时仍然更合适。这里有多个宿主、在线与离线数据源、账号与权限环境,以及独立演进的列表、详情、搜索和同步模块,重复工厂开始形成另一套需要维护的规则。
当时的 Swinject 2.7.1 支持类型化注册、运行时参数、Assembly 与 Assembler、对象 scope、容器继承和同步 resolver,正好能把重复组装集中起来,又不要求业务对象继承框架类型。
先划清宿主与 SDK 的边界
| 宿主提供 | SDK 负责 |
|---|---|
| 账号、凭据和组织上下文 | 文档身份、索引与展示状态 |
| 共享传输、连接状态和生命周期 | 缓存分区、离线队列和同步策略 |
| 导航出口、深链、日志与分析 | 权限投影、协作事件与文档会话 |
| 系统存储能力与安全策略 | 文档相关功能页面 |
SDK 不读取宿主单例,宿主也不必知道内部使用哪种缓存。双方只在入口交换协议与配置。
对象图自然分为三层:Host 跟随 SDK 实例;Account 跟随当前账号,持有认证、缓存、权限和同步;Document Session 跟随打开的文档,持有编辑、协作连接与待提交操作。
宿主组装入口包含账号对象图,每个打开的文档再拥有更短生命周期的会话。
账号切换应该结束旧图并创建新图,而不是仅仅改一个全局 token。文档关闭则结束整个会话。
业务类型仍然明确声明依赖
final class DocumentListViewModel {
private let repository: DocumentRepositoryProtocol
private let permissionPolicy: PermissionPolicy
private let analytics: AnalyticsTracking
init(
repository: DocumentRepositoryProtocol,
permissionPolicy: PermissionPolicy,
analytics: AnalyticsTracking
) {
self.repository = repository
self.permissionPolicy = permissionPolicy
self.analytics = analytics
}
}
Swinject 只在边界满足这个初始化方法,并不改变类型自身的设计。宿主提供认证、网络、存储、日志和展示能力,组装入口生成少量功能根对象,容器到此为止。
Assembly 表达模块归属
注册不应该全部堆进几百行的 Container.swift。模块各自声明提供什么实现、需要哪些协议:
import Swinject
final class DocumentDataAssembly: Assembly {
private let account: AccountContext
init(account: AccountContext) {
self.account = account
}
func assemble(container: Container) {
container.register(DocumentRemoteService.self) { resolver in
HTTPDocumentRemoteService(
transport: resolver.resolve(HostTransport.self)!,
credential: self.account.credential
)
}
.inObjectScope(.container)
container.register(DocumentCache.self) { _ in
SQLiteDocumentCache(namespace: self.account.cacheNamespace)
}
.inObjectScope(.container)
container.register(OfflineOperationStore.self) { resolver in
SQLiteOfflineOperationStore(
cache: resolver.resolve(DocumentCache.self)!
)
}
.inObjectScope(.container)
container.register(DocumentRepositoryProtocol.self) { resolver in
DocumentRepository(
remote: resolver.resolve(DocumentRemoteService.self)!,
cache: resolver.resolve(DocumentCache.self)!,
offlineOperations: resolver.resolve(OfflineOperationStore.self)!
)
}
.inObjectScope(.container)
}
}
列表、详情、搜索与同步拥有自己的 Assembly。宿主与账号能力位于父子容器,生产、演示与测试选择不同组合:
let hostAssembler = Assembler([
HostInfrastructureAssembly(capabilities: hostCapabilities)
])
let accountAssembler = Assembler([
DocumentDataAssembly(account: account),
PermissionAssembly(account: account),
CollaborationAssembly(account: account),
DocumentListAssembly(),
DocumentDetailAssembly(),
SearchAssembly()
], parent: hostAssembler)
收益不是少写几次初始化,而是把模块的输入与输出放在可审查的位置。
Scope 表达业务生命周期
| Scope | 适用对象 | 语义 |
|---|---|---|
| Transient | 一次性命令、轻量格式化器 | 每次独立创建 |
| Graph | 同一功能创建的协调器和子 view model | 单次解析图内共享 |
| Container | 当前环境的网络、缓存、repository、同步器 | 与 Host 或 Account 容器同寿命 |
| Weak | 可复用但不应被容器延长生命的辅助对象 | 外部不再持有时释放 |
所有服务都标成 .container,只是把全局单例换了个写法。账号图结束时,还需要明确取消操作、关闭订阅和释放外部持有,不能仅依赖容器变量被置空。
运行时 document ID 由功能工厂接收,创建独立会话。谁拥有对象图,谁也负责结束它的世界。
容器层级不是页面层级
子容器可以使用父注册并局部覆盖,适合宿主与账号环境。但不需要每个页面都新建一层,否则很难判断最终实现来自哪里。
Host infrastructure
└── Account-scoped Document SDK
└── Feature object graph
只有配置和生命周期都发生变化时,新的容器边界才有意义。
离线不是一个 Boolean
可读本地快照但远端版本未知、有未提交操作、正在重连重放、发生远端冲突、账号切换后旧队列必须隔离,这些不是一个 isOffline 能说明的状态。
我们定义 ConnectivityMonitoring、OfflineOperationStore、DocumentSyncing 和 ConflictResolving 等能力,在 Account 组装入口组合本地存储、远端服务、同步与冲突策略。功能只依赖 repository 协议和明确同步状态,不关心数据来自网络还是恢复中的队列。
测试因此可以构造“离线编辑后重连”或“有待提交操作时切账号”,无需修改生产代码里的全局网络状态。
权限与协同也是持续变化的输入
权限不会在打开页面时就固定。协作者、分享规则和远端操作都可能持续变化。PermissionProviding、CollaborationStreaming 与 DocumentSession 将这些职责分开:Account 提供账号和组织上下文,Session 合并单篇文档的权限、远端事件与本地编辑状态。
View model 可以显式声明这些依赖,测试权限撤销、协作者变化和会话重连,而不必启动整个宿主。
对交付产生的实际影响
多个宿主共享同一套 Assembly,不再复制微妙不同的初始化顺序;离线、演示与测试成为明确组合;账号相关对象拥有共同清理边界;宿主契约可由最小 demo 独立验证;模块可以各自演进;关键功能根对象有统一的解析测试入口。
但容器也会引入缺失注册、错误 scope、字符串命名出错和循环依赖。我们给它设定了约束:
- Container 只出现在组装入口与 Assembly,业务类型不依赖 Resolver。
- 注册只创建对象,不发请求、不迁移数据库。
- 重要业务变体使用语义协议,不靠字符串名字区分。
- 每个容器 scope 都要说明业务寿命。
- 即使框架支持属性注入解决循环,也先把循环视为设计信号。
- 注册在单线程启动阶段完成,并发使用同步 Resolver。
- 对关键功能根执行解析测试,缺失依赖在启动验证时暴露。
依赖注入真正表达的原则
依赖由外向内提供,业务对象不向外搜寻运行环境;对象寿命应贴合进程、SDK、账号、文档与页面各自不同的业务边界;显式设计也不意味着每一行组装都必须手写。
如果只有几个稳定服务,手动注入仍然足够。这里的复杂性已经存在,Swinject 只是把它集中到可替换、可测试的组装层。重要的不是容器替我们创建了多少对象,而是对象不再需要猜测自己运行在哪个世界。