Android applications integrate numerous third-party SDKs for payment processing, analytics, and subscription services, each with its own initialization requirements and clean up procedures. Managing these lifecycles manually leads to memory leaks, resource exhaustion, and subtle bugs when SDKs outlive their usefulness. Hilt’s component scoping and lifecycle hooks provide a solution, but understanding how to properly leverage them requires knowledge of the internal mechanisms that make lifecycle management possible.
Let’s dive into SDK lifecycle management with Hilt, including:
- Exploring the modern dependency injection library
- How Hilt’s component hierarchy maps to Android lifecycles
- How lifecycle callbacks enable automatic clean-up
- How scoping determines SDK lifetime
- Why proper lifecycle management prevents resource leaks.
Using RevenueCat’s Purchases SDK as an example, you’ll see how different scoping strategies affect SDK initialization and disposal, when clean-up is necessary versus premature, and the performance implications of each approach. This isn’t a guide on integrating SDKs. It’s an exploration of the lifecycle machinery that makes proper resource management possible.
The fundamental problem: SDK lifecycle mismatches
Third-party SDKs often have explicit lifecycle requirements:
1// RevenueCat initialization
2Purchases.configure(
3 PurchasesConfiguration.Builder(context, apiKey).build()
4)
5
6// Later, when done
7Purchases.sharedInstance.close()
The challenge: when is “later”? If you initialize in Application.onCreate() and never call close(), the SDK holds resources (connections, caches, listeners) for the entire app lifetime. If you initialize in Activity.onCreate() and call close() in onDestroy(), you might prematurely dispose of resources during configuration changes.
The naive approach is manual lifecycle tracking:
1class SubscriptionActivity : AppCompatActivity() {
2 private lateinit var purchases: Purchases
3
4 override fun onCreate(savedInstanceState: Bundle?) {
5 super.onCreate(savedInstanceState)
6 purchases = Purchases.configure(
7 PurchasesConfiguration.Builder(this, apiKey).build()
8 )
9 }
10
11 override fun onDestroy() {
12 super.onDestroy()
13 if (isFinishing) { // Only close if truly finishing
14 purchases.close()
15 }
16 }
17}
This works but is brittle. You must remember to call close() in every activity that uses the SDK, and you must correctly distinguish configuration changes from true destruction. Hilt’s scoping and lifecycle mechanisms automate this pattern.
Hilt’s component hierarchy: Mapping to Android lifecycles
Hilt provides a predefined component tree that aligns with Android’s lifecycle:
1SingletonComponent (application lifetime)
2 └── ActivityRetainedComponent (survives configuration changes)
3 ├── ActivityComponent (activity lifetime)
4 │ ├── FragmentComponent (fragment lifetime)
5 │ └── ViewComponent (view lifetime)
6 └── ViewModelComponent (ViewModel lifetime)
Each component has an associated scope annotation and lifecycle:
- The
SingletonComponentwith@Singletonscope lives from application creation to application destruction - The
ActivityRetainedComponentwith@ActivityRetainedScopedscope lives from activity creation to activity finish, surviving rotation - The
ActivityComponentwith@ActivityScopedscope lives from activity creation to activity destruction, getting destroyed on rotation - The
ViewModelComponentwith@ViewModelScopedscope lives from ViewModel creation to ViewModel cleared - The
FragmentComponentwith@FragmentScopedscope lives from fragment creation to fragment destruction
Understanding this hierarchy is critical for SDK lifecycle management. The component you choose determines when initialization and clean-up occur.
The lifecycle callback mechanism: RetainedLifecycle internals
Hilt provides lifecycle hooks through the RetainedLifecycle interface:
1public interface RetainedLifecycle {
2 @MainThread
3 void addOnClearedListener(@NonNull OnClearedListener listener);
4
5 @MainThread
6 void removeOnClearedListener(@NonNull OnClearedListener listener);
7
8 interface OnClearedListener {
9 void onCleared();
10 }
11}
This interface is implemented by ActivityRetainedLifecycle and ViewModelLifecycle, providing clean-up callbacks for retained components.
RetainedLifecycleImpl: the implementation
The internal implementation reveals important constraints:
1public final class RetainedLifecycleImpl
2 implements ActivityRetainedLifecycle, ViewModelLifecycle {
3
4 private final Set<RetainedLifecycle.OnClearedListener> listeners = new HashSet<>();
5 private boolean onClearedDispatched = false;
6
7 @Override
8 public void addOnClearedListener(@NonNull RetainedLifecycle.OnClearedListener listener) {
9 ThreadUtil.ensureMainThread();
10 throwIfOnClearedDispatched();
11 listeners.add(listener);
12 }
13
14 @Override
15 public void removeOnClearedListener(@NonNull RetainedLifecycle.OnClearedListener listener) {
16 ThreadUtil.ensureMainThread();
17 throwIfOnClearedDispatched();
18 listeners.remove(listener);
19 }
20
21 public void dispatchOnCleared() {
22 ThreadUtil.ensureMainThread();
23 onClearedDispatched = true;
24 for (RetainedLifecycle.OnClearedListener listener : listeners) {
25 listener.onCleared();
26 }
27 }
28
29 private void throwIfOnClearedDispatched() {
30 if (onClearedDispatched) {
31 throw new IllegalStateException(
32 "There was a race between the call to add/remove an OnClearedListener and onCleared(). "
33 + "This can happen when posting to the Main thread from a background thread, "
34 + "which is not supported.");
35 }
36 }
37}
All lifecycle operations must happen on the main thread. This is enforced via ThreadUtil.ensureMainThread(), preventing concurrent access issues. Listeners are stored in a HashSet, not a thread-safe collection. The main thread requirement eliminates the need for synchronization overhead.
The onClearedDispatched flag ensures clean-up happens exactly once. Attempting to add listeners after clean-up throws an exception. The exception message explicitly describes the race condition scenario, making debugging easier.
This implementation prioritizes simplicity and performance over flexibility. Lifecycle callbacks are strictly single-threaded and one-way.
The ViewModel bridge: how clean-up is triggered
The lifecycle callback is triggered through Android’s ViewModel.onCleared():
1static final class ActivityRetainedComponentViewModel extends ViewModel {
2 private final ActivityRetainedComponent component;
3 private final SavedStateHandleHolder savedStateHandleHolder;
4
5 ActivityRetainedComponentViewModel(
6 ActivityRetainedComponent component,
7 SavedStateHandleHolder savedStateHandleHolder) {
8 this.component = component;
9 this.savedStateHandleHolder = savedStateHandleHolder;
10 }
11
12 @Override
13 protected void onCleared() {
14 super.onCleared();
15 ActivityRetainedLifecycle lifecycle =
16 EntryPoints.get(component, ActivityRetainedLifecycleEntryPoint.class)
17 .getActivityRetainedLifecycle();
18 ((RetainedLifecycleImpl) lifecycle).dispatchOnCleared();
19 }
20}
This is elegant. Hilt doesn’t reimplement lifecycle tracking. Instead, it piggybacks on Android’s existing ViewModel retention mechanism. The ActivityRetainedComponent is stored inside ActivityRetainedComponentViewModel. Android’s ViewModelStore retains the ViewModel across configuration changes, and when the Activity finishes (not just rotates), ViewModelStore clears the ViewModel. Then ViewModel.onCleared() is called, which dispatches Hilt’s lifecycle callbacks.This design means Hilt’s lifecycle hooks have the same semantics as ViewModel.onCleared(). They fire when the Activity is finishing, not during configuration changes.
SDK lifecycle patterns: three scoping strategies
Let’s examine three different approaches to managing RevenueCat’s Purchases SDK, each with different lifecycle implications.
Pattern 1: Singleton scope (application lifetime)
The simplest approach is application-scoped initialization:
1@Module
2@InstallIn(SingletonComponent::class)
3object PurchasesModule {
4
5 @Provides
6 @Singleton
7 fun providePurchases(
8 @ApplicationContext context: Context
9 ): Purchases {
10 return Purchases.configure(
11 PurchasesConfiguration.Builder(context, BuildConfig.REVENUECAT_API_KEY)
12 .appUserId(null) // Anonymous user
13 .build()
14 )
15 }
16}
The SDK initializes on first injection, typically during Application creation. It never gets disposed of. The SDK lives for the entire app lifetime.
The Google Play Billing connection is maintained throughout app lifetime with approximately a few KB overhead. The subscription cache stays in memory, typically less than 100 KB for most apps. Network listeners remain active for the app lifetime.
This approach makes sense when subscription features are core to the app, like subscription-based apps where user subscription status is checked frequently across many screens. The overhead is acceptable given usage patterns.
This approach should be avoided when subscription features are rarely used, like a one-time tip jar in settings. It’s also not ideal when the app is resource-constrained and runs on low-end devices, or when the Billing SDK conflicts with other payment integrations.
Pattern 2: ActivityRetainedScoped (survives configuration changes)
For feature-scoped usage, tie the SDK to a specific section:
1@Module
2@InstallIn(ActivityRetainedComponent::class)
3object FeatureScopedPurchasesModule {
4
5 @Provides
6 @ActivityRetainedScoped
7 fun providePurchases(
8 @ApplicationContext context: Context,
9 lifecycle: ActivityRetainedLifecycle
10 ): Purchases {
11 val purchases = Purchases.configure(
12 PurchasesConfiguration.Builder(context, BuildConfig.REVENUECAT_API_KEY)
13 .build()
14 )
15
16 // Register clean-up callback
17 lifecycle.addOnClearedListener {
18 purchases.close()
19 }
20
21 return purchases
22 }
23}
The SDK initializes on first injection within the Activity’s component graph. It gets disposed of when the Activity finishes, after isFinishing becomes true.
When the screen rotates, the SDK instance is retained with no reinitialization. The same ActivityRetainedComponent is reused during activity recreation. When the activity finishes, lifecycle.onCleared() fires and purchases.close() is called.
Let’s trace the complete lifecycle:
1User navigates to SubscriptionActivity
2 → ActivityRetainedComponentManager.generatedComponent() called
3 → ActivityRetainedComponentViewModel created (if first time)
4 → ActivityRetainedComponent built
5 → providePurchases() called
6 → Purchases.configure() initializes SDK
7 → OnClearedListener registered
8
9User rotates device
10 → Activity destroyed
11 → New Activity created
12 → Same ActivityRetainedComponentViewModel retrieved from ViewModelStore
13 → Same ActivityRetainedComponent reused
14 → Same Purchases instance injected (no reinitialization)
15
16User navigates away (finishes Activity)
17 → Activity.onDestroy() with isFinishing=true
18 → ViewModelStore cleared
19 → ActivityRetainedComponentViewModel.onCleared() called
20 → RetainedLifecycleImpl.dispatchOnCleared() called
21 → OnClearedListener.onCleared() invoked
22 → purchases.close() executes
This is the sweet spot for feature-scoped SDKs. You get configuration change resilience without app-wide overhead.
Pattern 3: ViewModelScoped (per-ViewModel instances)
For ViewModel-specific SDK instances:
1@Module
2@InstallIn(ViewModelComponent::class)
3object ViewModelScopedPurchasesModule {
4
5 @Provides
6 @ViewModelScoped
7 fun providePurchases(
8 @ApplicationContext context: Context,
9 lifecycle: ViewModelLifecycle
10 ): Purchases {
11 val purchases = Purchases.configure(
12 PurchasesConfiguration.Builder(context, BuildConfig.REVENUECAT_API_KEY)
13 .build()
14 )
15
16 lifecycle.addOnClearedListener {
17 purchases.close()
18 }
19
20 return purchases
21 }
22}
23
24@HiltViewModel
25class SubscriptionViewModel @Inject constructor(
26 private val purchases: Purchases
27) : ViewModel() {
28 fun loadOfferings() {
29 purchases.getOfferingsWith { offerings ->
30 // Handle offerings
31 }
32 }
33}
The SDK initializes when the ViewModel is created. It gets disposed of when the ViewModel is cleared, which happens when the Activity or Fragment is destroyed. Each ViewModel gets its own SDK instance, providing complete isolation.
This pattern works well when you have multiple independent purchase flows with separate state, when testing requires isolated SDK instances, or when different ViewModels need different SDK configurations.
Creating multiple SDK instances means multiple Google Play Billing connections. A SubscriptionViewModel instance creates Purchases instance #1 with Google Play Billing connection #1. An UpgradeViewModel instance creates Purchases instance #2 with Google Play Billing connection #2. Each connection has overhead of approximately 200 to 500ms initialization time and a few KB memory. For most apps, a single shared instance is more efficient.
Configure debug & release SDK initialization
For example when developing with RevenueCat, you can set different SDK configurations for debug builds to enable testing without real purchases. Since RevenueCat’s StoreKit Testing (Test Store) allows you to simplify subscription testing flows without connecting to real App Store servers. For release mode, you can set the real private key, and allow your users to purchases the products.
Different build variants need different SDK configurations:
1@Module
2@InstallIn(SingletonComponent::class)
3object PurchasesModule {
4
5 @Provides
6 @Singleton
7 fun providePurchases(
8 @ApplicationContext context: Context
9 ): Purchases {
10 return
11 if (BuildConfig.DEBUG) {
12 Purchases.configure(
13 PurchasesConfiguration.Builder(
14 context,
15 BuildConfig.REVENUECAT_DEBUG_API_KEY
16 )
17 .dangerousSettings(
18 DangerousSettings(
19 autoSyncPurchases = false // Manual sync for testing
20 )
21 )
22 .build()
23 ).apply {
24 // Enable Test Store mode for local testing
25 Purchases.logLevel = LogLevel.DEBUG
26 }
27 }
28 } else {
29 Purchases.configure(
30 PurchasesConfiguration.Builder(
31 context,
32 BuildConfig.REVENUECAT_API_KEY
33 )
34 .build()
35 )
36 }
37}
Hilt processes each variant separately, so debug and release modules don’t conflict. The debug module configures the SDK with verbose logging and manual sync control, making it easier to test subscription flows with Test Store. The release module uses production configuration with automatic sync enabled.
The Test Store integration allows you to test subscription scenarios locally without making real purchases. You can verify that your SDK lifecycle management works correctly across different subscription states, test upgrade and downgrade flows, and validate that clean-up happens properly when users cancel subscriptions. This is particularly valuable when testing ActivityRetainedScoped SDKs because you can simulate the complete lifecycle from subscription purchase through activity rotation to final clean-up.
RevenueCat’s singleton protection: a special case
Before diving into why clean-up matters, it’s important to understand that RevenueCat’s Purchases SDK has built-in singleton protection that prevents accidental instance duplication. Look at the implementation:
1fun configure(configuration: PurchasesConfiguration): Purchases {
2 if (isConfigured) {
3 if (backingFieldSharedInstance?.purchasesOrchestrator?.currentConfiguration == configuration) {
4 infoLog { ConfigureStrings.INSTANCE_ALREADY_EXISTS_WITH_SAME_CONFIG }
5 return sharedInstance
6 } else {
7 infoLog { ConfigureStrings.INSTANCE_ALREADY_EXISTS }
8 }
9 }
10 return PurchasesFactory().createPurchases(configuration).also {
11 sharedInstance = it
12 }
13}
When you call Purchases.configure() with the same configuration multiple times, RevenueCat returns the existing singleton instance instead of creating a new one. This means the ActivityRetainedScoped and ViewModelScoped patterns described earlier won’t actually create multiple SDK instances if you’re using the same API key and configuration.
However, if you call configure() with different configurations (different API keys, different user IDs, different settings), RevenueCat will create a new instance and replace the singleton. The old instance still exists in memory until you explicitly call close() on it.
This singleton protection is specific to RevenueCat. Many other SDKs don’t have this safeguard and will happily create multiple instances if you call their initialization methods repeatedly. Examples include Firebase Analytics, Amplitude, Mixpanel, and many payment SDKs. For these SDKs, the resource leak scenarios described below are real concerns.
The key takeaway: even though RevenueCat protects against duplicate instances with the same configuration, the lifecycle management patterns with Hilt still matter because they ensure proper clean-up when you’re done with the SDK, handle configuration changes correctly, and establish patterns that work across all SDKs, not just RevenueCat.
Why clean-up matters: Resource leak analysis
Let’s analyze what happens without proper clean-up, both for RevenueCat and for SDKs without singleton protection.
Memory leak scenario: Singleton without scoping
1class Application : Application() {
2 override fun onCreate() {
3 super.onCreate()
4 Purchases.configure(...) // Initialized once
5 // Never closed
6 }
7}
The Google Play Billing Service connection is maintained indefinitely. Service binding holds Context reference and prevents garbage collection of associated objects. The subscription state cache grows over time with cached offerings, products, and purchase history. There’s no eviction policy without close(). Network listeners stay active throughout app lifetime, including webhook listeners for subscription changes and background sync operations.
For a singleton SDK in a subscription app, this is acceptable. The resources are used throughout the app lifetime. The ‘leak’ is intentional.
Resource exhaustion scenario: Activity scope without clean-up
1class SubscriptionActivity : AppCompatActivity() {
2 private lateinit var purchases: Purchases
3
4 override fun onCreate(savedInstanceState: Bundle?) {
5 super.onCreate(savedInstanceState)
6 purchases = Purchases.configure(...)
7 // Never closed, even when Activity finishes
8 }
9}
For RevenueCat specifically, thanks to the singleton protection, calling configure() with the same configuration repeatedly just returns the existing instance. You won’t create 10 separate instances. However, you still have a problem: you’re holding references to the singleton in multiple Activity instances, and you never call close() to clean up when you’re truly done with subscriptions.
More importantly, this pattern becomes a real problem with SDKs that don’t have singleton protection. Consider an analytics SDK without this safeguard:
1class AnalyticsActivity : AppCompatActivity() {
2 private lateinit var analytics: AnalyticsSDK
3
4 override fun onCreate(savedInstanceState: Bundle?) {
5 super.onCreate(savedInstanceState)
6 analytics = AnalyticsSDK.initialize(context, apiKey)
7 // Never closed, even when Activity finishes
8 }
9}
When the user visits AnalyticsActivity for the first time, AnalyticsSDK instance #1 is created with its own event buffer and network queue. When the user navigates away and returns, AnalyticsActivity instance #2 is created, AnalyticsSDK instance #2 is created with another event buffer and network queue. But AnalyticsSDK instance #1 still exists because it was never cleaned up. After 10 visits, you have 10 AnalyticsSDK instances, 10 event buffers, 10 network queues, and a cumulative memory leak.
Eventually, the app will hit resource limits through either memory exhaustion or connection limits.
The isFinishing() pitfall: configuration changes vs. destruction
Manual clean-up often looks like this:
1override fun onDestroy() {
2 super.onDestroy()
3 if (isFinishing) {
4 purchases.close()
5 }
6}
This works but is fragile. Every Activity using the SDK must implement this pattern, which is easy to forget. Unit tests must mock isFinishing correctly, adding testing complexity. Edge cases emerge, like when finish() is called from onCreate() or during programmatic recreation.Hilt’s ActivityRetainedLifecycle eliminates these concerns. The callback fires exactly when the Activity is finishing, with no manual checks needed.
Advanced pattern: Conditional SDK initialization
Sometimes SDKs should only be initialized under certain conditions:
1@Module
2@InstallIn(SingletonComponent::class)
3object ConditionalPurchasesModule {
4
5 @Provides
6 @Singleton
7 fun providePurchases(
8 @ApplicationContext context: Context,
9 userRepository: UserRepository
10 ): Purchases? {
11 return if (userRepository.currentUser?.canMakePurchases == true) {
12 Purchases.configure(
13 PurchasesConfiguration.Builder(context, BuildConfig.REVENUECAT_API_KEY)
14 .build()
15 )
16 } else {
17 null // No SDK for users without purchase capability
18 }
19 }
20}
Nullable injection handles the conditional initialization:
1@HiltViewModel
2class SubscriptionViewModel @Inject constructor(
3 private val purchases: Purchases? // Nullable
4) : ViewModel() {
5
6 fun loadOfferings() {
7 purchases?.getOfferingsWith { offerings ->
8 // Handle offerings
9 } ?: run {
10 // User can't make purchases, show alternative UI
11 }
12 }
13}
This pattern prevents unnecessary SDK initialization for users who can’t use it.
Performance profiling: measuring SDK lifecycle impact
To understand the performance implications, let’s measure RevenueCat initialization and disposal.
Initialization cost
1@Provides
2@Singleton
3fun providePurchases(
4 @ApplicationContext context: Context
5): Purchases {
6 val startTime = SystemClock.elapsedRealtime()
7
8 val purchases = Purchases.configure(
9 PurchasesConfiguration.Builder(context, apiKey).build()
10 )
11
12 val duration = SystemClock.elapsedRealtime() - startTime
13 Log.d("PurchasesModule", "Initialization took ${duration}ms")
14
15 return purchases
16}
Cold start typically takes 50 to 100ms (depending on your machines), which includes Google Play Billing connection. Warm start typically takes 10 to 20ms when the Billing Service is already bound. Configuration blocks the calling thread, creating main thread impact.
You can optimize by initializing on a background thread:
1@Provides
2@Singleton
3fun providePurchases(
4 @ApplicationContext context: Context,
5 @IODispatcher dispatcher: CoroutineDispatcher
6): Lazy<Purchases> = lazy {
7 runBlocking(dispatcher) {
8 Purchases.configure(
9 PurchasesConfiguration.Builder(context, apiKey).build()
10 )
11 }
12}
Using Lazy defers initialization until first access. If that access is from a co-routine on a background dispatcher, initialization happens off the main thread.
Clean-up cost
1lifecycle.addOnClearedListener {
2 val startTime = SystemClock.elapsedRealtime()
3 purchases.close()
4 val duration = SystemClock.elapsedRealtime() - startTime
5 Log.d("PurchasesModule", "clean-up took ${duration}ms")
6}
Clean-up duration typically takes 10 to 50ms. Operations include disconnecting from Billing Service, canceling pending queries, and clearing caches.
Clean-up happens in ViewModel.onCleared(), which is synchronous. Long clean-up blocks the main thread during Activity finish.
You can optimize expensive clean-up by moving to a background thread:
1lifecycle.addOnClearedListener {
2 CoroutineScope(Dispatchers.IO).launch {
3 purchases.close()
4 }
5}
However, be cautious. If clean-up must happen before the process terminates, background clean-up might not complete.
Memory profiling: comparing scoping strategies
Using Android Studio’s Memory Profiler, we can measure SDK memory overhead. With singleton scope, initial allocation is approximately 500 KB. After visiting 10 screens, memory usage remains at approximately 500 KB because the same instance is reused. Memory growth is zero.
With ActivityRetainedScoped while visiting 10 different screens, screen 1 adds 500 KB during initialization. When screen 1 finishes, 500 KB is freed during clean-up. Screen 2 adds 500 KB during initialization. When screen 2 finishes, 500 KB is freed. This pattern continues, maintaining a steady state of approximately 500 KB with one instance at a time.
Without clean-up while visiting 10 screens, screen 1 adds 500 KB, screen 2 adds another 500 KB, screen 3 adds another 500 KB, continuing until screen 10 adds another 500 KB. The total accumulates to approximately 5 MB from 10 instances leaked.
The measurements show that proper clean-up is critical for activity-scoped SDKs.
Testing SDK lifecycle management
Hilt’s lifecycle integration requires special consideration in tests.
Unit testing: mocking lifecycle callbacks
1@HiltAndroidTest
2class SubscriptionViewModelTest {
3
4 @get:Rule
5 var hiltRule = HiltAndroidRule(this)
6
7 @Inject
8 lateinit var purchases: Purchases
9
10 @Inject
11 lateinit var lifecycle: ActivityRetainedLifecycle
12
13 @Before
14 fun setup() {
15 hiltRule.inject()
16 }
17
18 @Test
19 fun testcleanup() {
20 // Verify clean-up callback is registered
21 val callbackCaptor = argumentCaptor<ActivityRetainedLifecycle.OnClearedListener>()
22 verify(lifecycle).addOnClearedListener(callbackCaptor.capture())
23
24 // Simulate lifecycle cleared
25 callbackCaptor.firstValue.onCleared()
26
27 // Verify SDK clean-up
28 verify(purchases).close()
29 }
30}
This test verifies that the module registers a clean-up callback and that the callback calls close().
Integration testing: verifying clean-up happens
1@HiltAndroidTest
2@HiltAndroidRule(ComponentActivity::class)
3class SDKLifecycleTest {
4
5 @get:Rule
6 var hiltRule = HiltAndroidRule(this)
7
8 @Test
9 fun testActivityFinishTriggersclean-up() {
10 val scenario = launchActivity<SubscriptionActivity>()
11
12 // Inject to trigger SDK initialization
13 scenario.onActivity { activity ->
14 // Trigger injection
15 val viewModel: SubscriptionViewModel by activity.viewModels()
16 viewModel.loadOfferings()
17 }
18
19 // Verify SDK is initialized
20 val purchases = Purchases.sharedInstance
21 assertNotNull(purchases)
22
23 // Finish activity
24 scenario.close()
25
26 // Verify clean-up (this requires SDK instrumentation)
27 // In practice, you'd verify through memory leaks or state checks
28 }
29}
This test verifies the full lifecycle from initialization on activity creation to clean-up on activity finish.
Replacing SDKs in tests
For tests that don’t need the real SDK:
1@Module
2@InstallIn(SingletonComponent::class)
3@TestInstallIn(
4 components = [SingletonComponent::class],
5 replaces = [PurchasesModule::class]
6)
7object FakePurchasesModule {
8
9 @Provides
10 @Singleton
11 fun provideFakePurchases(): Purchases {
12 return mockk<Purchases>(relaxed = true)
13 }
14}
The @TestInstallIn annotation replaces the production module with a fake, avoiding real SDK initialization in tests.
Real-world case study: multi-SDK coordination
Many apps integrate multiple SDKs with interdependent lifecycles:
1@Module
2@InstallIn(ActivityRetainedComponent::class)
3object AnalyticsModule {
4
5 @Provides
6 @ActivityRetainedScoped
7 fun provideAnalytics(
8 @ApplicationContext context: Context,
9 purchases: Purchases, // Depends on Purchases SDK
10 lifecycle: ActivityRetainedLifecycle
11 ): Analytics {
12 val analytics = Analytics.initialize(context)
13
14 // Set user properties from subscription status
15 purchases.getCustomerInfoWith { customerInfo ->
16 analytics.setUserProperty("subscription_status", customerInfo.entitlements.active)
17 }
18
19 lifecycle.addOnClearedListener {
20 analytics.flush() // Ensure events are sent before clean-up
21 analytics.close()
22 }
23
24 return analytics
25 }
26}
Dagger’s dependency graph ensures Purchases is initialized before Analytics because Analytics depends on it. This initialization order is automatic based on the dependency graph.
Clean-up callbacks fire in registration order. If you need specific ordering, you can register multiple callbacks, but relying on callback order is fragile. A better approach is to handle dependencies explicitly:
1data class SDKCoordinator @Inject constructor(
2 private val purchases: Purchases,
3 private val analytics: Analytics,
4 private val lifecycle: ActivityRetainedLifecycle
5) {
6 init {
7 lifecycle.addOnClearedListener {
8 clean-upAll()
9 }
10 }
11
12 private fun clean-upAll() {
13 // Explicit clean-up order
14 analytics.close()
15 purchases.close()
16 }
17}
This coordinator pattern makes clean-up order explicit and testable.
Recap
SDK lifecycle management with Hilt leverages the framework’s component hierarchy and lifecycle callback mechanisms to automate initialization and clean-up. The component scoping determines SDK lifetime. Use @Singleton for application-wide SDKs, @ActivityRetainedScoped for feature-scoped SDKs that survive configuration changes, and @ViewModelScoped for ViewModel-specific instances. The RetainedLifecycle interface provides clean-up hooks that fire when components are destroyed, implemented through Android’s ViewModel.onCleared() mechanism.
The internal implementation reveals important constraints. Lifecycle callbacks are strictly main-thread operations enforced through ThreadUtil.ensureMainThread(). Clean-up is one-time-only with explicit race condition detection. The ViewModel bridge ensures clean-up happens when Activities finish rather than during configuration changes. Performance profiling shows that proper clean-up prevents memory leaks by avoiding cumulative SDK instances while scoping determines initialization cost, balancing application-wide overhead against feature-scoped deferred initialization.
Ready to tidy up your SDK?
Understanding these internals helps you make better architectural decisions:
- Choose appropriate scoping based on SDK usage patterns
- Register clean-up callbacks to prevent resource leaks
- Measure initialization and clean-up costs to optimize performance
- Coordinate multiple interdependent SDKs through explicit dependency management
While RevenueCat’s Purchases SDK has built-in singleton protection that prevents duplicate instances with the same configuration, many other SDKs lack this safeguard. The lifecycle management patterns demonstrated here work universally across all SDKs, ensuring proper resource management regardless of the SDK’s internal implementation.
Whether you’re integrating subscription services like RevenueCat, analytics platforms, or payment processors, Hilt’s lifecycle mechanisms provide a declarative, type-safe solution to the SDK lifecycle problem.

