依赖注入(四):当DI遇见声明式UI,从Flutter Riverpod反思SwiftUI的最佳实践

314 阅读7分钟

前三篇我们已经为iOS原生开发的DI打下了坚实的基础。现在,让我们把目光投向更广阔的领域,看看在现代化的声明式UI范式下,DI的思想是如何演进和应用的。这对于我们组内同时拥有Swift和Flutter技术栈的同学来说,尤其有价值。

今天,我们要讨论一个非常前沿且重要的话-题:依赖注入在声明式UI框架中应该如何实践?

随着SwiftUI和Flutter的兴起,我们的UI构建方式已经从命令式(“去做这个,然后做那个”)转变为声明式(“UI应该是这个状态”)。这种转变不仅仅影响了视图层,它也深刻地改变了我们对状态管理和依赖注入的思考方式。

组内有很多Flutter的同事对Riverpod框架非常熟悉。这是一个绝佳的机会,我们可以通过对比Riverpod的设计哲学,来反思和探索在SwiftUI中进行依赖注入的最佳实践。

1. 求同存异,先看Flutter的Riverpod

对于不熟悉Riverpod的Swift同学,可以把它简单理解为一个“超级强大”的DI和状态管理框架。它的核心是 Provider

一个Provider就是一个“提供者”,它可以向UI(在Flutter中是Widget)提供任何东西:

  • 一个服务的实例(如APIService
  • 一个计算后的值(如从多个状态组合出的新值)
  • 一个完整的ViewModel(在Flutter中通常叫StateNotifierChangeNotifier

Riverpod的核心特点:

  1. 与UI框架深度融合: Provider的生命周期和作用域可以与Widget树紧密绑定。当一个Widget不再需要某个Provider时,Provider可以被自动销毁(autoDispose),非常高效地管理内存。
  2. 天生的响应式: 当一个Provider所提供的数据发生变化时,所有“监听”了这个Provider的UI组件都会自动重建(刷新),以展示最新的状态。
  3. 编译时安全: 它摆脱了Flutter早期DI框架Provider需要依赖BuildContext(上下文)的缺点,可以在任何地方安全地访问,且没有运行时风险。
  4. 不仅仅是DI,更是状态管理: 这是最关键的一点。Riverpod通过Provider统一了“依赖注入”和“状态管理”这两个概念。你可以用同样的方式去“提供”一个无状态的APIService和一个有状态的CounterViewModel
// Riverpod 示例
// 1. 提供一个服务
final apiServiceProvider = Provider((ref) => ApiService());

// 2. 提供一个ViewModel(StateNotifier)
final counterProvider = StateNotifierProvider<Counter, int>((ref) {
  // 可以在这里读取其他provider!实现了依赖注入
  final apiService = ref.watch(apiServiceProvider);
  return Counter(apiService); // Counter是StateNotifier的子类
});

// 3. 在UI中使用
class MyScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 读取ViewModel的状态
    final count = ref.watch(counterProvider);
    // 读取ViewModel本身以调用其方法
    final counterNotifier = ref.read(counterProvider.notifier);

    return Scaffold(
      body: Center(child: Text('$count')),
      floatingActionButton: FloatingActionButton(
        onPressed: () => counterNotifier.increment(),
      ),
    );
  }
}

2. 对比Swift世界的DI:核心差异

通过观察Riverpod,我们可以发现它与我们之前讨论的Swinject等传统DI框架的核心差异:

  • 关注点不同:

    • Swinject (传统DI): 更关注 “对象图的构建” (Object Graph Construction)。它的核心任务是在应用启动时或需要时,正确地创建和连接好所有对象。它对UI层是“无知”的。
    • Riverpod: 更关注 “状态的提供与消费” (State Provision and Consumption)。它与UI层紧密耦合,其设计初衷就是为了服务于响应式的UI刷新。
  • 生命周期/作用域不同:

    • Swinject: 它的作用域(.container, .graph)与Container实例和resolve调用相关,与UI组件的生命周期没有直接关系。
    • Riverpod: Provider的生命周期可以和Widget的生命周期完全同步,实现了更精细、自动化的管理。

3. SwiftUI中的DI最佳实践

那么,在SwiftUI中,我们应该如何借鉴这些思想呢?

a. 苹果的原生方案:@EnvironmentObject

SwiftUI提供了一个原生的DI机制:@EnvironmentObject。你可以把一个对象注入到视图环境中,任何子视图都可以从中读取。

// 在根视图注入
let userSettings = UserSettings()
ContentView().environmentObject(userSettings)

// 在子视图中接收
struct SettingsView: View {
    @EnvironmentObject var settings: UserSettings

    var body: some View {
        Text("Username: \(settings.username)")
    }
}

优点:

  • 非常简单,苹果原生支持。

缺点:

  • 类型不安全: 如果你忘记在父视图注入environmentObject,App会在运行时直接崩溃。
  • 全局污染: 它更像一个“全局变量”,适用于传递真正全局的、与UI显示相关的状态(如主题、用户设置),但如果用它来注入大量的服务和ViewModel,会使依赖关系变得混乱和隐式。
  • 不适合注入服务: 它的设计初衷是传递ObservableObject,用于驱动UI刷新,而不是注入无状态的服务。

结论:@EnvironmentObject可用,但要谨慎。它适合做简单的UI状态分发,而非完整的DI解决方案。

b. 主流方案:“DI容器 + ViewModel”模式

社区目前公认的最佳实践,是结合我们前几篇学到的知识,将传统DI容器(如Swinject)与SwiftUI的@StateObject / @ObservedObject结合起来。

这个模式的思路是:DI容器负责幕后的对象创建,SwiftUI负责前台的状态观测和UI刷新,二者各司其职。

实践步骤:

  1. 在组合根中设置DI容器: 和以前一样,在App的入口处(@mainApp结构体或SceneDelegate)创建和配置我们的AssemblerContainer

    // App.swift
    @main
    struct MyApp: App {
        let assembler: Assembler
        
        init() {
            assembler = Assembler([
                NetworkAssembly(),
                ViewModelAssembly()
            ])
        }
    
        var body: some Scene {
            WindowGroup {
                // 从容器中解析出根视图的ViewModel
                // 注意这里,我们只解析一次,然后交给SwiftUI管理
                let rootViewModel = assembler.resolver.resolve(RootViewModel.self)!
                RootView(viewModel: rootViewModel)
            }
        }
    }
    
  2. 为View注入其专属的ViewModel: View不应该直接接触DI容器。View的唯一依赖就是它的ViewModel。ViewModel通过构造函数注入它所需要的所有服务。

    // ProductDetailView.swift
    struct ProductDetailView: View {
        // 使用@StateObject确保ViewModel的生命周期与View绑定
        // viewModel由父视图(或Coordinator)创建并传入
        @StateObject var viewModel: ProductDetailViewModel
    
        var body: some View {
            VStack {
                Text(viewModel.productName)
                if viewModel.isLoading {
                    ProgressView()
                }
                Button("Add to cart") {
                    viewModel.addToCart()
                }
            }
            .onAppear(perform: viewModel.fetchProduct)
        }
    }
    
  3. ViewModel从DI容器中创建,并注入依赖: 这一步发生在ViewModelAssembly中,我们之前已经很熟悉了。

    // ViewModelAssembly.swift
    class ViewModelAssembly: Assembly {
        func assemble(container: Container) {
            // ViewModel必须是 .transient
            container.register(ProductDetailViewModel.self) { (r, productId: String) in
                ProductDetailViewModel(
                    productId: productId,
                    apiService: r.resolve(APIService.self)!, // 自动解析服务
                    cartService: r.resolve(CartService.self)!
                )
            }.inObjectScope(.transient)
        }
    }
    

这个模式的巨大优势:

  • 职责清晰: Swinject管创建,ViewModel管业务逻辑和状态,View管渲染。
  • 强类型安全: 所有依赖都通过构造函数注入,编译时就能发现错误。
  • 可测试性极高: 你可以轻松地为ProductDetailViewModel创建mock的APIServiceCartService来进行单元测试,完全不用依赖任何UI。
  • 与SwiftUI和谐共存: 它没有破坏SwiftUI的声明式和响应式特性,而是为其提供了一个坚实的、可预测的数据和逻辑后端。

总结与思考

通过对比Riverpod,我们发现,虽然它在与UI的结合度上做得更“原生”,但其核心思想——将依赖(服务)和状态(ViewModel)统一通过某种机制(Provider)提供给UI——是相通的。

在SwiftUI中,我们虽然没有一个像Riverpod一样大一统的框架,但通过 “Swinject (DI容器) + @StateObject (状态管理)” 的组合,我们实现了一个逻辑上等价且非常强大的模式。

  • Riverpod的ref.watch(someProvider) ≈ SwiftUI的@StateObject / @ObservedObject 它们都负责监听状态变化并触发UI刷新。
  • Riverpod的Provider定义 ≈ Swinject的container.register 它们都负责定义如何创建依赖和状态对象。

最终,我们为SwiftUI应用构建了一个清晰的分层架构:

View -> ViewModel (状态和业务逻辑) -> Services (无状态的原子能力)

而依赖注入,就是将这些层次优雅地“粘合”在一起的最佳胶水。

在下一篇中,我们将回到更广义的架构讨论,看看DI思想的其他实现方式,比如通过路由(Coordinator)模式,并给出最终的架构选型建议。

敬请期待!