全部文章

文档 SDK 为什么选择 Swinject

把依赖组装留在系统边界,让 Host、账号和文档会话分别拥有自己的生命周期,离线与测试也成为可明确组合的运行环境。

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 跟随打开的文档,持有编辑、协作连接与待提交操作。

Host、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、字符串命名出错和循环依赖。我们给它设定了约束:

  1. Container 只出现在组装入口与 Assembly,业务类型不依赖 Resolver。
  2. 注册只创建对象,不发请求、不迁移数据库。
  3. 重要业务变体使用语义协议,不靠字符串名字区分。
  4. 每个容器 scope 都要说明业务寿命。
  5. 即使框架支持属性注入解决循环,也先把循环视为设计信号。
  6. 注册在单线程启动阶段完成,并发使用同步 Resolver。
  7. 对关键功能根执行解析测试,缺失依赖在启动验证时暴露。

依赖注入真正表达的原则

依赖由外向内提供,业务对象不向外搜寻运行环境;对象寿命应贴合进程、SDK、账号、文档与页面各自不同的业务边界;显式设计也不意味着每一行组装都必须手写。

如果只有几个稳定服务,手动注入仍然足够。这里的复杂性已经存在,Swinject 只是把它集中到可替换、可测试的组装层。重要的不是容器替我们创建了多少对象,而是对象不再需要猜测自己运行在哪个世界。

参考资料