生命周期感知的多模块启动模式

0 阅读11分钟

本文译自「The Android Startup Pattern: A Lifecycle-Aware, Multi-Module Approach」,原文链接medium.com/proandroidd…,由Ehab Elwan发布于2026年8月25日。

0.jpeg

声明:本文由我撰写,并借助人工智能工具进行了完善。

每个不断发展的 Android 项目最终都会催生出一个双头“上帝级”。

一方面,你的 Application 类会变成全局基础架构(第三方 SDK、崩溃报告器和跟踪工具)的垃圾场。另一方面,你的主入口点(通常是 MainViewModel)会被阻塞 UI 的启动逻辑堵塞。

它通常看起来像这样:

反模式:双头神类

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        // The framework dumping ground
        CrashReportingSDK.getInstance().setCollectionEnabled(true)
        HeavyUiSDK.initialize(context = this, ...) 
        AnalyticsSDK.initialize(this, "API_KEY")
        // ... 50 more lines of spaghetti
    }
}
class MainViewModel : ViewModel() {
    init {
        // The UI-blocking dumping ground
        updateRemoteConfigs()
        checkUserSessionToken()
        processPendingDeepLinks()
        prefetchHomeFeedData()
        // ... UI cannot render until this finishes
    }
}

将初始化过程拆分到这两个文件中会造成严重问题:

  1. 它违反了单一职责原则:应用程序的入口点被迫协调每个功能的内部运作,导致模块之间紧密耦合。
  2. 它忽略了进程生命周期:应用程序任务会在每次静默后台唤醒时无差别地运行,而当应用程序返回前台时,MainViewModel 任务无法重新触发。
  3. 它破坏了可测试性:将 SDK 初始化直接硬编码到入口点中,使得编写独立的单元测试变得极其困难,除非使用复杂的模拟设置。
  4. 它使隐私合规性复杂化:集中式的 SDK 存储使得在用户根据全球隐私法规(例如 GDPR、CCPA 和 CPRA)明确授予同意之前,动态暂停跟踪 SDK 变得极其困难。

为了解决这个问题,我们需要更智能的初始化策略。

Jetpack 应用启动:何时使用(以及何时避免使用)

在构建自定义解决方案之前,我们必须了解Jetpack App Startup。谷歌开发此库是为了解决一个特定问题:第三方 SDK 使用其自身的不可见内容提供程序 (ContentProvider) 进行自动初始化,这会严重增加冷启动时间。

何时使用:

它是初始化不依赖于应用程序内部业务逻辑的原始框架库(如 WorkManager 或 Crashlytics)的完美工具。

当它变得危险时:

Jetpack 应用启动会在 Application.onCreate() 之前运行。这意味着它会在你的依赖注入图构建完成之前执行。此外,在底层自动初始化跟踪或广告 SDK 可能违反 GDPR、CCPA 和 CPRA 等全球隐私法规。如果 SDK 在你的用户同意管理平台 (CMP) 评估用户选择之前收集数据,则你不符合相关法规。

对于一个强大的企业应用程序,你需要一个自定义的依赖注入驱动模式,用于应用程序级别的业务逻辑,该模式要尊重依赖注入、生命周期和用户同意。

核心概念:多重绑定和阶段

主应用程序模块不会显式调用功能设置,而是使用一种称为多重绑定的依赖注入概念来反转依赖关系。

无论你使用 Hilt、Dagger、Koin 还是 Metro,现代依赖注入框架都允许各个功能模块以静默的方式向全局集合贡献实现。主应用程序模块只需注入该集合并运行任务,完全无需了解这些任务的来源。

阶段合同

我们定义了优先级阶段(在你的:core模块中),并利用密封接口来保证开发人员只能接入我们明确支持的生命周期。

enum class StartupPhase(val order: Int) {
    // The app cannot function if this fails (e.g., Security Configs).
    CRITICAL(0),
    // Crucial for the first screen (e.g., Feature Flags).
    HIGH(1),
    // Standard startup tasks (e.g., Pre-warming caches).
    NORMAL(2),
    // Fire-and-forget, or things that can wait (e.g., Background Observers).
    LOW(3)
}
// Sealed to prevent rogue custom lifecycles
sealed interface StartupTask {
    val phase: StartupPhase get() = StartupPhase.NORMAL
// Tip: Always profile this timeout on low-end hardware under heavy CPU load!
    val timeoutMs: Long get() = 800L
    suspend operator fun invoke()
}
interface AppStartupTask : StartupTask
interface ForegroundTask : StartupTask
interface SessionStartupTask : StartupTask
// Thread-safe base class for tasks that SHOULD block and retry safely
abstract class IdempotentStartupTask : StartupTask {
    private val mutex = Mutex()
    @Volatile private var isCompleted = false
    final override suspend operator fun invoke() {
        if (isCompleted) return
        mutex.withLock {
            if (isCompleted) return@withLock
            execute()
            isCompleted = true
        }
    }
    protected abstract suspend fun execute()
}

注意: 由于这些任务在启动序列中执行,因此它们必须是主程序安全的。任何 CPU 密集型工作都应该切换到 Dispatchers.Default,而繁重的 I/O 操作(例如数据库读取或网络调用)应该使用 withContext 切换到 Dispatchers.IO(或者依赖于像 Room 和 Retrofit 这样的主程序安全库)。

冷启动:基础设施、观察员和共识

冷启动任务在应用程序进程驻留在内存中时运行一次。

这个生命周期完美地展现了挂起合约的强大功能。我们可以在不阻塞启动序列的情况下执行标准的阻塞代码或启动持续观察者(通过注入应用程序作用域的协程作用域)。这对于动态处理隐私许可非常有用。

功能实现

// A blocking task (delays the next phase until finished)
class SecurityConfigTask @Inject constructor(
    private val securityManager: SecurityManager
) : IdempotentStartupTask(), AppStartupTask {
    override val phase = StartupPhase.CRITICAL
    
    override suspend fun execute() {
        securityManager.initializeEncryptionKeys() 
    }
}
// A continuous task (fire-and-forget observer)
class PendingSyncObserverTask @Inject constructor(
    private val offlineSyncDao: OfflineSyncDao,
    private val syncManager: SyncManager,
    @ApplicationScope private val appScope: CoroutineScope
) : AppStartupTask {
    override val phase = StartupPhase.LOW
    override suspend fun invoke() {
        appScope.launch {
            offlineSyncDao.observePendingActions().collect { pendingItems ->
                if (pendingItems.isNotEmpty()) {
                    syncManager.startUploadProcess(pendingItems)
                }
            }
        }
    }
}
// A decentralized, dynamic consent-gated task (GDPR, CCPA, CPRA)
class AnalyticsConsentTask @Inject constructor(
    private val consentManager: ConsentManager,
    @ApplicationScope private val appScope: CoroutineScope
) : AppStartupTask {
    override val phase = StartupPhase.LOW
    override suspend fun invoke() {
        appScope.launch {
            // Continuously observes this specific vendor's consent state.
            // Handles cold start, delayed CMP acceptance, AND later revocation in settings!
            // Multiple tasks doing this takes virtually zero resources as suspended flows are cheap.
            consentManager.observeConsent(Vendor.FIREBASE_ANALYTICS).collect { isGranted ->
                Analytics.setCollectionEnabled(isGranted)
            }
        }
    }
}
// Example: How feature modules contribute tasks to the global Set in Hilt/Dagger
@Module
@InstallIn(SingletonComponent::class)
abstract class SecurityModule {
    @Binds
    @IntoSet
    abstract fun bindSecurityConfigTask(task: SecurityConfigTask): AppStartupTask
}

统一的任务运行器和错误恢复

为了在所有生命周期中安全地执行这些任务,避免因非关键性故障导致应用程序崩溃,我们在共享的 :core 模块中放置了一个 Kotlin 扩展函数。我们使用协程作用域 (coroutineScope) 结合每个任务的 try-catch 块,以便捕获并记录非关键性故障,而关键性故障则会立即取消同级任务并向上冒泡。

class CriticalStartupException(
    val failedTaskName: String, 
    cause: Throwable
) : Exception("Critical task failed: $failedTaskName", cause)
/**
 * Groups tasks by phase and runs them concurrently. 
 */
suspend fun Iterable<StartupTask>.executeAll() {
    val phases = this.groupBy { it.phase.order }.toSortedMap()
    
    for ((_, tasksInPhase) in phases) {
        coroutineScope {
            tasksInPhase.forEach { task -> 
                launch {
                   try {
                        withTimeout(task.timeoutMs) { task() }
                    } catch (e: TimeoutCancellationException) {
                        if (task.phase == StartupPhase.CRITICAL) {
                            throw CriticalStartupException("${task.javaClass.simpleName} timed out", e)
                        } else {
                            // Log non-fatal error
                        }
                    } catch (e: CancellationException) {
                        throw e 
                    } catch (e: Exception) {
                        if (task.phase == StartupPhase.CRITICAL) {
                            throw CriticalStartupException(task.javaClass.simpleName, e)
                        } else {
                            // Log non-fatal error
                        }
                    }
                } 
            }
        }
    }
}

编排器(:app 模块)

得益于我们的扩展功能,编曲器变得非常精简。

sealed interface StartupState {
    data object Loading : StartupState()
    data object Success : StartupState()
    data class FatalError(val exception: CriticalStartupException) : StartupState()
}
@Singleton
class AppInitializer @Inject constructor(
    private val appTasks: Set<@JvmSuppressWildcards AppStartupTask>,
    @ApplicationScope private val scope: CoroutineScope 
) {
    private val _startupState = MutableStateFlow<StartupState>(StartupState.Loading)
    val startupState: StateFlow<StartupState> = _startupState.asStateFlow()
    fun initialize() {
        // Reset state so the UI shows a loading spinner on retry
        _startupState.value = StartupState.Loading 
        
        scope.launch { 
            try {
                appTasks.executeAll()
                _startupState.value = StartupState.Success
            } catch (e: CriticalStartupException) {
                _startupState.value = StartupState.FatalError(e)
            }
        }
    }
}

将其连接到用户界面

要完成循环,你的 UI 层只需观察 AppInitializer(通常是通过将其注入到你的全局 MainViewModel 中),并对状态变化做出反应。

使用 AndroidX Core Splash Screen API,你可以将原生启动画面保持在屏幕上,直到编排完成:

// Inside your MainActivity
val splashScreen = installSplashScreen()
splashScreen.setKeepOnScreenCondition {
    viewModel.startupState.value == StartupState.Loading
}

一旦状态变为 Success,你的 Activity 就可以安全地渲染主导航图。如果抛出 FatalError 异常,则渲染一个带有“重试”按钮的备用屏幕,该按钮会调用 viewModel.retryInitialization() 方法。保持启动画面显示直到 StartupState 状态离开 Loading 状态,可以消除 UI 闪烁、竞态条件和突然启动崩溃。

重试的黄金法则:强制幂等性

如果用户在致命错误屏幕上点击“重试”,onRetry 函数会再次调用 appInitializer.initialize()。由于这会重新运行整个初始化过程,因此任务必须是幂等的。

扩展 IdempotentStartupTask 会自动强制执行此操作:如果 CrashReportingTask 在第一次尝试中成功,但 SecurityConfigTask 失败,则重试将通过其 @Volatile 和 Mutex 状态检查安全地跳过 CrashReportingTask,并立即继续执行失败的任务。

热身启动:前台任务

现代 Android 开发中存在一个隐藏的风险:后台唤醒。营销和归因工具经常会发送静默推送通知,其目的仅仅是为了 ping 一下设备,并跟踪应用是否仍然安装在设备上。

当这些静默的卸载跟踪 ping 唤醒你的应用时,操作系统会创建进程并调用 Application.onCreate()。如果你的冷启动序列立即触发大量 API 同步、初始化占用大量资源的 UI 相关 SDK 并启动分析工具,那么一个简单的后台 ping 就会造成巨大的电量消耗。讽刺的是,这种静默的电量消耗往往正是用户卸载应用的根本原因!

通过将依赖于 UI 的 SDK 或繁重的数据同步任务移至 ForegroundTask,我们可以将它们与 Android 的 ProcessLifecycleOwner 关联起来。由于在静默后台唤醒期间不会调用 onStart(),因此这些繁重的任务自然会被延迟到用户实际将应用切换到屏幕时才执行。

class RemoteConfigTask @Inject constructor(
    private val configManager: ConfigManager
) : IdempotentStartupTask(), ForegroundTask {
    override val phase = StartupPhase.HIGH
    override val timeoutMs = 5000L // Giving the network a bit more time if needed
    
    override suspend fun execute() { 
        configManager.updateRemoteConfigs()
    }
}
@Singleton
class AppLifecycleObserver @Inject constructor(
    private val foregroundTasks: Set<@JvmSuppressWildcards ForegroundTask>,
    @ApplicationScope private val scope: CoroutineScope
) : DefaultLifecycleObserver {
    override fun onStart(owner: LifecycleOwner) {
        scope.launch { 
            try {
                foregroundTasks.executeAll() 
            } catch (e: CriticalStartupException) {
                // Log non-fatal foreground failure and do not crash the app
            }
        }
    }
}

用户会话:会话启动任务

如果你的应用启动但用户已登出,则尝试同步用户配置文件将会失败。会话任务必须完全与你的身份验证状态关联,完全绕过操作系统生命周期。

注意:SessionOrchestrator 只处理会话相关任务。注销时,清除本地数据库和撤销令牌的操作应严格委托给你的 AuthManager。

@Singleton
class SessionOrchestrator @Inject constructor(
    private val authManager: AuthManager,
    private val sessionTasks: Set<@JvmSuppressWildcards SessionStartupTask>,
    @ApplicationScope private val scope: CoroutineScope
) {
    fun startObserving() {
        scope.launch {
            authManager.authState
                .map { it is AuthState.LoggedIn }
                .distinctUntilChanged()
                .filter { isLoggedIn -> isLoggedIn }
                .collect {
                    try {
                        sessionTasks.executeAll()
                    } catch (e: CriticalStartupException) {
                        // Log failure or route to UI, but keep collector alive
                    }
                }
        }
    }
}

协调启动序列

我们在 Application 类中委托执行。请注意,这个入口点非常简洁,并且完全不涉及功能逻辑:

@HiltAndroidApp
class MyApplication : Application() {
    @Inject lateinit var appInitializer: AppInitializer
    @Inject lateinit var lifecycleObserver: AppLifecycleObserver
    @Inject lateinit var sessionOrchestrator: SessionOrchestrator
    override fun onCreate() {
        super.onCreate()
        appInitializer.initialize()
        ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver)
        sessionOrchestrator.startObserving()
    }
}

UI 层:完善 MainViewModel

我们已成功清理了入口点,将全局基础架构迁移到 AppStartupTask,将全局 UI SDK 迁移到 ForegroundTask。

但是,那些阻塞了 MainViewModel 的屏幕特定数据获取操作(例如 prefetchHomeFeedData())该如何处理呢?这些逻辑根本不应该包含在全局启动序列中。获取首页信息流的数据应该延迟执行,即在主屏幕实际渲染时才执行。

一种常见的反模式是使用 LaunchedEffect(key) 或更新、高度优化的 SideEffect(key) API 以命令式的方式指示 ViewModel 获取数据。另一种反模式是在 ViewModel 的初始化代码块中启动协程。这两种反模式都会使测试变得困难,并破坏响应式 UI 模式。

相反,应采用声明式流程方法。数据会在用户界面开始感知数据时才延迟加载。

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val fetchFeedUseCase: FetchFeedUseCase 
) : ViewModel() {
    // No init blocks. No LaunchedEffect. No SideEffect.
    // Data fetching starts automatically when the UI subscribes.
    val uiState: StateFlow<HomeState> = flow {
            // Wrap a one-shot suspend call into a cold flow
            emit(fetchFeedUseCase())
        }.map {
          // Map result to UI state (e.g. Success, Error) here 
        }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = HomeState.Loading
        )
}
@Composable
fun HomeScreen(viewModel: HomeViewModel = hiltViewModel()) {
    // Lifecycle-aware collection stops wasting resources when the app is backgrounded
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    
    // Render UI...
}

为什么这种架构在生产环境中胜出

除了优化入口点之外,这种架构还解决了几个大型企业面临的挑战:

  1. 去中心化隐私合规性: 由于任务可以注入持续观察者,因此对隐私敏感的 SDK 可以动态响应 ConsentManager。它们会在获得特定供应商的许可后初始化,并在稍后在设置中撤销许可后优雅地关闭,从而使你的 :core:consent 模块与第三方 SDK 依赖项完全解耦。
  2. 防止静默唤醒导致的电池消耗: 通过将 UI 相关的 SDK 和繁重的同步操作转移到 ForegroundTask,你可以消除设备在用户口袋中时因后台推送 ping(例如卸载跟踪)而导致的静默资源消耗。
  3. 测试人员的理想之选: 你可以通过传递模拟任务集来对编排器进行单元测试,并在完全隔离的环境下测试各个初始化任务。此外,由于 ViewModel 不再使用 init 块或 Compose 副作用来获取启动数据,因此你可以在单元测试中实例化它们,而不会触发意外的网络调用。

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!