依赖注入最佳实践:模块化设计、作用域管理、循环依赖解决、性能优化

说实话,依赖注入框架用起来不难。真正考验功力的,是你能不能把它用好。我见过太多项目,Hilt 或 Koin 是加上了,但代码反而更乱。今天我就把这几年的实战经验掰开揉碎,跟你聊聊依赖注入的最佳实践。

核心观点:依赖注入不是银弹。用不好,它就是另一层复杂度。用好了,它能让你的架构如虎添翼。

模块化设计:别把所有东西塞一个模块

我刚开始用 Dagger 时,犯过一个典型错误——把所有依赖都放在一个 AppModule 里。结果呢?编译越来越慢,谁都不敢动那个文件。后来我学乖了。

模块化设计的原则其实很简单:按功能边界拆分。每个模块只负责自己领域内的依赖提供。

// 网络层模块
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder()
            .connectTimeout(30, TimeUnit.SECONDS)
            .build()
    }

    @Provides
    @Singleton
    fun provideRetrofit(client: OkHttpClient): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://api.example.com/")
            .client(client)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }
}

// 数据层模块
@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {
    @Provides
    @Singleton
    fun provideUserRepository(api: UserApi): UserRepository {
        return UserRepositoryImpl(api)
    }
}

我个人习惯把模块分成三类:

  • 基础设施模块:网络、数据库、文件存储
  • 业务模块:用户、订单、支付
  • 功能模块:日志、分析、推送

这样做的好处很明显。你想想看,如果网络库要升级,你只需要改 NetworkModule。其他模块根本不用动。我在项目中遇到过,因为模块拆分清晰,一次 OkHttp 升级只花了半小时就搞定了。

作用域管理:别让对象活太久

作用域管理是依赖注入里最容易出问题的地方。说白了,就是你要想清楚:这个对象应该活多久?

作用域 生命周期 适用场景
@Singleton 应用进程 网络客户端、数据库实例
@ActivityScoped Activity 生命周期 界面状态、导航控制器
@FragmentScoped Fragment 生命周期 列表适配器、局部状态
@ViewModelScoped ViewModel 生命周期 业务逻辑、数据缓存

这里有个坑,我必须要提醒你。千万不要把 Activity 作用域的对象注入到 Singleton 里。为什么?因为 Activity 销毁后,Singleton 还持有它的引用,这就造成了内存泄漏。

我曾经在项目里犯过这个错。一个网络请求回调持有了 Activity 引用,导致用户退出页面后,内存一直释放不了。最后用 MAT 分析堆转储才找到问题。从那以后,我每次写注入都会问自己:这个对象的生命周期匹配吗?

对于 Koin 用户,作用域管理稍微灵活一些:

// Koin 作用域示例
val myModule = module {
    scope(named("userScope")) {
        scoped { UserSession() }
        scoped { UserRepository(get()) }
    }
}

// 在 Activity 中使用
val scope = getKoin().createScope("userScope", named("userScope"))
val session = scope.get<UserSession>()
// 记得在 onDestroy 中关闭
scope.close()

循环依赖解决:别让 A 和 B 互相等

循环依赖是依赖注入里最让人头疼的问题。A 依赖 B,B 依赖 A,结果两个都创建不出来。我见过不少团队在这里卡住。

解决思路其实就三种:

  1. 重构设计:把互相依赖的逻辑抽到第三个类里
  2. 使用 Provider:延迟获取依赖,打破直接引用
  3. 接口分离:用接口代替具体实现

我个人最推荐第一种。你想想看,如果两个类互相依赖,说明它们职责边界没划清楚。

// 不好的设计:循环依赖
class OrderService(private val paymentService: PaymentService)
class PaymentService(private val orderService: OrderService)

// 好的设计:引入中间层
class OrderService(private val paymentGateway: PaymentGateway)
class PaymentService(private val paymentGateway: PaymentGateway)
class PaymentGateway() // 统一管理支付逻辑

如果实在改不了,Hilt 和 Koin 都提供了延迟注入的机制:

// Hilt 使用 Lazy
class OrderService @Inject constructor(
    private val paymentService: Lazy<PaymentService>
) {
    fun process() {
        paymentService.get().pay()
    }
}

// Koin 使用 inject
class OrderService : KoinComponent {
    private val paymentService: PaymentService by inject()
}

小技巧:如果你发现循环依赖,先别急着用延迟注入。停下来想想,是不是设计出了问题。延迟注入只是治标,重构才是治本。

性能优化:别让注入成为瓶颈

依赖注入的性能问题,通常出现在两个方面:启动时间和内存占用。Hilt 和 Koin 的优化策略不太一样。

Hilt 性能优化:

  • 使用 @Reusable 代替 @Singleton,减少对象缓存
  • 避免在 Application 中注入大量对象
  • 使用 @EntryPoint 按需获取依赖

Koin 性能优化:

  • 使用 single 代替 factory,减少对象创建
  • 模块懒加载:module(createdAtStart = false)
  • 使用 KoinComponent 接口代替全局注入
// Hilt 按需注入示例
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AnalyticsEntryPoint {
    fun getAnalyticsService(): AnalyticsService
}

// 在需要的地方获取
val entryPoint = EntryPointAccessors.fromApplication(
    context,
    AnalyticsEntryPoint::class.java
)
val service = entryPoint.getAnalyticsService()

我记得有一次,一个同事抱怨 App 启动变慢了。一查,发现 Application 里注入了十几个 Repository。每个 Repository 又依赖网络、数据库等。启动时全都要初始化,不慢才怪。

解决方案很简单:按需注入,延迟初始化。把那些启动时不需要的依赖,改成懒加载。启动时间直接降了 40%。

性能黄金法则:只注入你当前需要的。不要为了图方便,把所有依赖都提前准备好。依赖注入是工具,不是目的。

依赖注入最佳实践知识体系 依赖注入最佳实践 模块化设计 作用域管理 循环依赖解决 性能优化 按功能边界拆分 基础设施/业务/功能 @Singleton / @ActivityScoped 生命周期匹配 重构设计 / Provider 接口分离 按需注入 / 延迟初始化 @Reusable / 懒加载

好了,以上就是依赖注入最佳实践的四个核心方向。模块化设计让代码更清晰,作用域管理让内存更安全,循环依赖解决让架构更健壮,性能优化让应用更流畅。这些经验都是我在实际项目中一点点踩坑总结出来的。希望你能少走一些弯路。