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:
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:
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:
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:
This interface is implemented by ActivityRetainedLifecycle and ViewModelLifecycle, providing clean-up callbacks for retained components.
RetainedLifecycleImpl: the implementation
The internal implementation reveals important constraints:
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():
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:
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:
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:
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:
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:
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:
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
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
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:
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:
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:
Nullable injection handles the conditional initialization:
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
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:
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
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:
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
This test verifies that the module registers a clean-up callback and that the callback calls close().
Integration testing: verifying clean-up happens
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:
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:
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:
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.

