Monday, December 1, 2025
Jaewoong Eum (skydoves)
Author
π Article & References
-
RemoteCompose: Another Paradigm for Server-Driven UI in Jetpack Compose: In this article, youβll explore what RemoteCompose is, understand its core architecture, and discover the benefits it brings to dynamic screen design with Jetpack Compose.
-
Finger Shadows in Compose: This post describes how Romain Guy used the GPU shader API on Android to build a βfinger shadowsβ effect: it treats the userβs finger (or stylus) as a 3-D capsule and computes soft shadows based on a fixed light source, making the UI react to pointer input with realistic shadow rendering. The implementation lets developers customize shadow size, orientation, light-source position and softness, giving a flexible way to simulate finger-cast shadows in a Jetpack Compose UI.
-
Pragmatic Modularization: The Case for Wiring Modules: This article argues for using a βwiring-moduleβ pattern when modularizing Android apps, introducing a thin, intermediate module that sits between the app module and feature implementation modules.
-
Android Developers: Fundamentals of testing Android apps: Testing is an integral part of the app development process. By running tests against your app consistently, you can verify your app's correctness, functional behavior, and usability before you release it publicly. You can manually test your app by navigating through it. You might use different devices and emulators, change the system language, and try to generate every user error or traverse every user flow.
-
Android Developers: Android 16 QPR2 is Released: Today we're releasing Android 16 QPR2, bringing a host of enhancements to user experience, developer productivity, and media capabilities. It marks a significant milestone in the evolution of the Android platform as the first release to utilize a minor SDK version.
-
JetBrains: The Ultimate KMP Watchlist: Level Up Your Skills in 10 Talks: For Week 3 of KMP Level Up, weβve curated the Top 10 KotlinConf talks that cover the full spectrum of adoption, from massive production case studies to deep dives into the compiler. Whether you are looking for architectural inspiration, hard data to convince your stakeholders, or just want to understand the engineering magic under the hood, the answers are out there.
-
Android Developers: What's new in the Jetpack Compose December '25 release: Today, the Jetpack Compose December β25 release is stable. This contains version 1.10 of the core Compose modules and version 1.4 of Material 3 (see the full BOM mapping), adding new features and major performance improvements.
-
Let's defuse the Compose BOM: The article argues that the Jetpack Compose Bill of Materials (BOM) is largely redundant for typical Gradle-based Android projects, because Composeβs own module metadata already enforces consistent version alignment across related libraries.
-
Android Developers: Composition tracing: Traces are often the best source of information when first looking into a performance issue. They allow you to form a hypothesis of what the issue is and where to start looking. There are two levels of tracing supported on Android: system tracing and method tracing.
π€ Conference & Speaking & Videos
-
Android Developers: What's new in Android Studio's AI Agent: Discover how the AI agent in Android Studio can dramatically improve your efficiency and app quality. Discover practical, AI-powered features like intelligent code transformation, automatic version upgrades, and a new suite of UI-specific tools designed to help you build better apps, faster.
-
White-Labelling Your Compose and XML UI with Design Tokens: In this session, weβll walk through Nutmegβs real-world journey in building a scalable, multi-themed design system that powers both the Nutmeg app and the Chase UK app. This case study explores how we tackled the challenge of white-labelling β enabling multiple branded user experiences from a single codebase β by deeply integrating design tokens and modular architecture into our Android development process.
-
A deep dive on the lifecycle-aware coroutines APIs: Collecting in a lifecycle-aware manner is essential for saving system resources. Since coroutines and flows are the recommended solution for asynchronous programming on Android, there are APIs that do most of the heavy-lifting work for you. Namely: repeatOnLifecycle, flowWithLifecycle, and Composeβs collectAsStateWithLifecycle. When building for Android, you should include these libraries in your toolbox. But even if youβre doing KMP, you should keep this in mind.
-
Structured Concurrency: The paradigm shift: For decades, concurrent programming has meant wrestling with complexity, resource leaks, and stray processes. Structured Concurrency presents a paradigm shift, changing how we write, read, and reason about concurrent code. This session cuts through the hype to reveal the core principle: concurrent tasks should have a clear beginning, end, and scope, just like any other code block.
-
Android Developers: Navigation 3 API overview: Learn Jetpack Navigation 3, Google's new library for building navigation in Android apps. Discover how to use keys to represent navigable content, manage your back stack, and create
NavEntrys to contain your Composable content. -
JetBrains: How Android devs can advance their career with KMP: Kotlin Multiplatform (KMP) can boost an Android developer's career. This webinar features expert opinions on KMP's impact, exploring its use in various apps and companies. Attendees discuss KMP's challenges and benefits, offering advice for those considering KMP adoption.
π οΈ Releases & Open-Source
-
What's new in Kotlin 2.3.0-RC2: The Kotlin 2.3.0-RC2 release is out! The Kotlin plugins that support 2.3.0-RC2 are bundled in the latest versions of IntelliJ IDEA and Android Studio. You don't need to update the Kotlin plugin in your IDE. All you need to do is change the Kotlin version to 2.3.0-RC2 in your build scripts
-
Jetpack Release, December 3, 2025: This week's Jetpack Release Notes include Compose 1.10.0, SwipeRefreshLayout 1.2.0, and bug fixes in Activity 1.12.1, NavigationEvent 1.0.1, ExifInterface 1.4.2, and Wear Compose 1.5.6.
- Compose 1.10.0, the "December '25 release", is stable today with performance improvements, retain APIs, plus some neat new animation features.
- SwipeRefreshLayout 1.2.0 is out as part of a concerted effort to get long running alphas to a stable state.
- There were a lot of other releases this week including Ink 1.0.0-rc01, Compose 1.11.0-alpha01 (new
visiblemodifier!), Navigation3 1.1.0-alpha01 (entries as shared elements!) and a bunch of XR library updates.
π AOSP
1.
Move gap-buffer slot table into its own package
A refactoring that moves the SlotTable and associated classes into its own package. This is a step in a larger refactoring that will eventually allow a new composer implementation, based on a link buffer instead of a gap buffer, to land behind a flag.
ποΈ Dove Letter Article
1. Understanding Entry Points in Hilt and how it works under the hood
Dependency injection frameworks excel at wiring dependencies through constructor and field injection, but what happens when you need to access dependencies from code that Hilt doesn't control? System-instantiated Views, third-party libraries that manage their own object creation, and legacy code that can't be refactored all face the same challenge: they can't use constructor injection because Hilt doesn't create them. This is where entry points come in, a mechanism that bridges the gap between Hilt's dependency graph and non-Hilt code through compile-time validated interface-based access.
In this article, you'll dive deep into how @EntryPoint works under the hood, exploring how the annotation processor generates entry point implementations, how components expose dependencies through interface inheritance, how EntryPoints.get() performs type-safe casting at runtime, and the design patterns that make entry points both flexible and performant. This isn't a guide on using @EntryPoint. It's an exploration of the compiler and runtime machinery that makes pull-based dependency access possible.
The fundamental problem: Accessing dependencies from outside Hilt
Hilt excels at push-based injection. You annotate a class with @AndroidEntryPoint or use constructor injection, and Hilt automatically provides dependencies. But what about code that Hilt doesn't control?
Consider a custom View inflated from XML:
class CustomImageView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : AppCompatImageView(context, attrs) {
// How do we get ImageLoader here?
// Can't use constructor injection - Android instantiates this from XML
// Can't use field injection - Views aren't @AndroidEntryPoint
private val imageLoader: ImageLoader = ???
}
The Android system creates the View by calling the constructor with Context and AttributeSet. You can't add an ImageLoader parameter. Field injection doesn't work because Views can't be annotated with @AndroidEntryPoint (they're not activities, fragments, or services).
The naive approach is to create a static singleton:
object ImageLoaderHolder {
lateinit var instance: ImageLoader
}
@HiltAndroidApp
class MyApp : Application() {
@Inject lateinit var imageLoader: ImageLoader
override fun onCreate() {
super.onCreate()
ImageLoaderHolder.instance = imageLoader
}
}
class CustomImageView(...) : AppCompatImageView(...) {
private val imageLoader = ImageLoaderHolder.instance
}
This works but is fragile. You must remember to initialize the holder, you lose compile-time type safety, and you introduce global mutable state. Entry points solve this by providing structured, type-safe access to the dependency graph.
The annotation taxonomy: Declaring entry points
Entry points introduce two annotations that work together to create access points into Hilt components.
@EntryPoint: Marking the interface
The @EntryPoint annotation marks an interface as a dependency access point:
@Retention(CLASS)
@Target(ElementType.TYPE)
@GeneratesRootInput
public @interface EntryPoint {}
The CLASS retention means entry points are available during annotation processing but not at runtime (except @EarlyEntryPoint which uses RUNTIME). The @GeneratesRootInput meta-annotation indicates this generates input for Hilt's root processor.
Entry points must be interfaces. They cannot be classes, enums, or annotations. This constraint is enforced during annotation processing.
@InstallIn: Specifying the target component
Every entry point must be paired with @InstallIn to specify which component provides the dependencies:
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ImageLoaderEntryPoint {
fun getImageLoader(): ImageLoader
}
The @InstallIn annotation takes an array of component classes, allowing installation into multiple components:
@EntryPoint
@InstallIn(ActivityComponent::class, FragmentComponent::class)
interface AnalyticsEntryPoint {
fun getAnalytics(): Analytics
}
This creates the entry point in both ActivityComponent and FragmentComponent. Each component will independently implement the interface.
The code generation machinery: From annotation to implementation
When you declare an entry point, Hilt's annotation processor generates several artifacts to wire everything together.
Aggregated metadata generation
For each entry point, the processor generates an aggregated metadata class:
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ImageLoaderEntryPoint {
fun getImageLoader(): ImageLoader
}
Generated metadata:
package hilt_aggregated_deps;
@AggregatedDeps(
components = "dagger.hilt.components.SingletonComponent",
entryPoints = "com.example.ImageLoaderEntryPoint"
)
class _com_example_ImageLoaderEntryPoint { }
This metadata class serves as a marker. Hilt's root processor scans the entire classpath for all classes annotated with @AggregatedDeps, collecting all entry points across all compilation units. This enables multi-module projects where entry points in library modules are discovered and installed into app components.
The aggregation pattern is critical for incremental compilation. Each module generates its own metadata independently, and the root processor aggregates them during final app compilation.
Package-private entry point wrapping
If your entry point is package-private, Hilt generates a public wrapper:
// Package-private in com.example.internal
@EntryPoint
@InstallIn(SingletonComponent::class)
internal interface InternalEntryPoint {
fun getFoo(): Foo
}
Generated public wrapper:
package com.example.internal;
@Generated("dagger.hilt.processor.internal.aggregateddeps.PkgPrivateEntryPointGenerator")
@InstallIn(SingletonComponent.class)
@EntryPoint
public interface HiltWrapper_InternalEntryPoint extends InternalEntryPoint {
// Empty interface that just extends the package-private one
}
This wrapper allows Hilt components (which are in generated packages) to implement package-private entry points. The component extends HiltWrapper_InternalEntryPoint, which extends InternalEntryPoint, giving the component access to the package-private interface methods.
Component implementation generation
The root processor makes the generated component implement all entry points installed in it. For SingletonComponent with the ImageLoaderEntryPoint:
@Singleton
@Component(modules = {/* all modules */})
public abstract class SingletonC
extends SingletonComponent
implements
ImageLoaderEntryPoint, // Your entry point
GeneratedComponent, // Hilt marker interface
// ... all other entry points installed in SingletonComponent
{
@Inject ImageLoader imageLoader;
@Override
public ImageLoader getImageLoader() {
return imageLoader; // Dagger-generated implementation
}
}
The component literally implements the entry point interface. When you declare fun getImageLoader(): ImageLoader in the entry point, the component provides an implementation that retrieves the dependency from its graph.
This implementation is generated by Dagger, not Hilt. Hilt tells Dagger to add the interface to the component's superinterfaces, and Dagger generates the provision method implementations like it would for any component interface method.
The runtime access mechanism: EntryPoints.get() internals
With the entry point interface and component implementation in place, the runtime access is surprisingly simple.
The EntryPoints.get() implementation
Looking at the actual implementation:
public static <T> T get(Object component, Class<T> entryPoint) {
if (component instanceof GeneratedComponent) {
if (component instanceof TestSingletonComponent) {
// Validate that @EarlyEntryPoint uses EarlyEntryPoints.get()
Preconditions.checkState(
!hasAnnotationReflection(entryPoint, EARLY_ENTRY_POINT),
"Interface, %s, annotated with @EarlyEntryPoint should be called with "
+ "EarlyEntryPoints.get() rather than EntryPoints.get()",
entryPoint.getCanonicalName());
}
return entryPoint.cast(component);
} else if (component instanceof GeneratedComponentManager) {
return get(((GeneratedComponentManager<?>) component).generatedComponent(), entryPoint);
} else {
throw new IllegalStateException(
String.format(
"Given component holder %s does not implement %s or %s",
component.getClass(), GeneratedComponent.class, GeneratedComponentManager.class));
}
}
The method accepts either a GeneratedComponent (the actual component) or a GeneratedComponentManager (the wrapper around the component). If passed a manager, it unwraps to get the component, then performs a type cast to the entry point interface.
This cast is safe because the annotation processor validates at compile time that every entry point is installed in the correct component, and the component generation ensures the component implements all its entry points. The runtime cast will always succeed if the code compiles.
Component manager unwrapping
The GeneratedComponentManager pattern is how Hilt associates components with Android framework classes:
@HiltAndroidApp
class MyApp : Application(), GeneratedComponentManagerHolder {
private lateinit var componentManager: ApplicationComponentManager
override fun onCreate() {
super.onCreate()
componentManager = ApplicationComponentManager {
DaggerSingletonC.builder()
.applicationContextModule(ApplicationContextModule(this))
.build()
}
}
override fun componentManager() = componentManager
}
The Application implements GeneratedComponentManagerHolder, which provides access to ApplicationComponentManager, which holds the actual SingletonComponent instance. This indirection allows lazy component creation and provides a stable reference across configuration changes for retained components.
When you call EntryPoints.get(application, ImageLoaderEntryPoint::class.java), it unwraps through these layers to get the actual component, then casts to the entry point interface.
Platform-specific accessors: EntryPointAccessors
Hilt provides convenience methods for common Android entry points:
object EntryPointAccessors {
@JvmStatic
fun <T> fromApplication(context: Context, entryPoint: Class<T>): T =
EntryPoints.get(Contexts.getApplication(context.applicationContext), entryPoint)
@JvmStatic
fun <T> fromActivity(activity: Activity, entryPoint: Class<T>): T =
EntryPoints.get(activity, entryPoint)
@JvmStatic
fun <T> fromFragment(fragment: Fragment, entryPoint: Class<T>): T =
EntryPoints.get(fragment, entryPoint)
@JvmStatic
fun <T> fromView(view: View, entryPoint: Class<T>): T =
EntryPoints.get(view, entryPoint)
}
These helpers handle context unwrapping. fromApplication accepts any Context and unwraps to get the Application instance, which is needed to access SingletonComponent. fromActivity, fromFragment, and fromView directly access their respective components.
The Kotlin versions provide reified type parameters for cleaner syntax:
val imageLoader = EntryPointAccessors.fromApplication<ImageLoaderEntryPoint>(context)
.getImageLoader()
This is equivalent to the Java version but avoids the explicit class parameter.
Component hierarchy and entry point installation
Entry points respect Hilt's component hierarchy, but they don't inherit down the tree. Understanding this is critical for proper entry point usage.
The component tree structure
Hilt's component hierarchy looks like this:
SingletonComponent (Application scope)
βββ ActivityRetainedComponent (@ActivityRetainedScoped)
βββ ActivityComponent (@ActivityScoped)
β βββ FragmentComponent (@FragmentScoped)
β βββ ViewComponent (@ViewScoped)
β βββ ViewWithFragmentComponent
βββ ViewModelComponent (@ViewModelScoped)
Each component has a parent component (except SingletonComponent). Child components can access dependencies from parent components through Dagger's subcomponent mechanism.
Entry point installation semantics
Entry points installed in a component are only accessible from that specific component. They don't propagate to child components:
@EntryPoint
@InstallIn(ActivityComponent::class)
interface ActivityEntryPoint {
fun getAnalytics(): Analytics
}
This entry point is accessible from ActivityComponent but not from FragmentComponent, ViewComponent, or ViewModelComponent. If you try to access it from a fragment:
val analytics = EntryPointAccessors.fromFragment<ActivityEntryPoint>(fragment)
.getAnalytics() // Runtime exception!
This will throw ClassCastException because FragmentComponent doesn't implement ActivityEntryPoint.
To access from multiple components, install in each one:
@EntryPoint
@InstallIn(ActivityComponent::class, FragmentComponent::class)
interface AnalyticsEntryPoint {
fun getAnalytics(): Analytics
}
Now the entry point is available from both components. Each component independently implements the interface.
Entry points for component creation
Entry points are used internally by Hilt to create child components. Look at how FragmentComponent is created:
public class FragmentComponentManager implements GeneratedComponentManager<Object> {
@EntryPoint
@InstallIn(ActivityComponent.class)
public interface FragmentComponentBuilderEntryPoint {
FragmentComponentBuilder fragmentComponentBuilder();
}
private Object createComponent() {
return EntryPoints.get(fragment.getHost(), FragmentComponentBuilderEntryPoint.class)
.fragmentComponentBuilder()
.fragment(fragment)
.build();
}
}
The FragmentComponentManager uses an entry point installed in ActivityComponent to get the FragmentComponentBuilder. This creates the parent-child relationship: ActivityComponent provides the builder for FragmentComponent.
This pattern repeats throughout Hilt's component hierarchy. Entry points enable child components to access their builders from parent components without tight coupling.
Real-world usage patterns: When and how to use entry points
Understanding the internals helps recognize when entry points are the right tool and when they're being abused.
Pattern 1: Custom View dependency access
Custom views inflated from XML need dependencies but can't use constructor injection:
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ImageLoaderEntryPoint {
fun getImageLoader(): ImageLoader
}
class CustomImageView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : AppCompatImageView(context, attrs) {
private val imageLoader: ImageLoader
init {
if (!isInEditMode) {
imageLoader = EntryPointAccessors
.fromApplication<ImageLoaderEntryPoint>(context)
.getImageLoader()
}
}
fun loadImage(url: String) {
imageLoader.load(url).into(this)
}
}
The isInEditMode check prevents accessing Hilt dependencies during Android Studio preview rendering, which doesn't have the full application context.
Pattern 2: Third-party library integration
Some libraries manage their own object lifecycle and need access to your dependencies. Consider integrating a third-party navigation library that doesn't support dependency injection:
// Third-party library interface (you don't control this)
interface ScreenFactory {
fun createScreen(screenId: String): Screen
}
// Your implementation using Hilt dependencies
class AppScreenFactory @Inject constructor(
private val userRepository: UserRepository,
private val analytics: Analytics
) : ScreenFactory {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ScreenFactoryEntryPoint {
fun getScreenFactory(): AppScreenFactory
}
override fun createScreen(screenId: String): Screen {
return when (screenId) {
"profile" -> ProfileScreen(userRepository, analytics)
"settings" -> SettingsScreen(userRepository)
else -> DefaultScreen()
}
}
companion object {
fun create(context: Context): ScreenFactory {
return EntryPointAccessors
.fromApplication<ScreenFactoryEntryPoint>(context)
.getScreenFactory()
}
}
}
// Register with third-party library (in Application.onCreate)
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
NavigationLibrary.setScreenFactory(AppScreenFactory.create(this))
}
}
The third-party library calls ScreenFactory.createScreen() whenever it needs a screen, but has no knowledge of Hilt. Entry points bridge this gap by allowing your factory implementation to access Hilt dependencies while conforming to the library's interface.
Pattern 3: ViewModel factory implementation
Hilt's own ViewModel factory uses entry points extensively:
public final class HiltViewModelFactory implements ViewModelProvider.Factory {
@EntryPoint
@InstallIn(ViewModelComponent::class)
public interface ViewModelFactoriesEntryPoint {
@HiltViewModelMap
Map<Class<?>, Provider<ViewModel>> getHiltViewModelMap()
@HiltViewModelAssistedMap
Map<Class<?>, Object> getHiltViewModelAssistedMap()
}
@EntryPoint
@InstallIn(ActivityComponent::class)
interface ActivityCreatorEntryPoint {
@HiltViewModelMap.KeySet
Map<Class<?>, Boolean> getViewModelKeys()
ViewModelComponentBuilder getViewModelComponentBuilder()
}
private <T extends ViewModel> T createViewModel(
ViewModelComponent component,
Class<T> modelClass,
CreationExtras extras
) {
Provider<? extends ViewModel> provider =
EntryPoints.get(component, ViewModelFactoriesEntryPoint.class)
.getHiltViewModelMap()
.get(modelClass)
if (provider == null) {
throw new IllegalStateException(
"Expected the @HiltViewModel-annotated class " + modelClass.getName()
+ " to be available in the multi-binding")
}
return (T) provider.get()
}
public static ViewModelProvider.Factory createInternal(
Activity activity,
ViewModelProvider.Factory delegateFactory
) {
ActivityCreatorEntryPoint entryPoint =
EntryPoints.get(activity, ActivityCreatorEntryPoint.class)
return new HiltViewModelFactory(
entryPoint.getViewModelKeys(),
delegateFactory,
entryPoint.getViewModelComponentBuilder()
)
}
}
This demonstrates multiple entry points in different components. ViewModelFactoriesEntryPoint accesses the ViewModel multibinding map from ViewModelComponent. ActivityCreatorEntryPoint gets the builder and key set from ActivityComponent.
The factory is created with Activity (which provides ActivityComponent access), then uses that to build ViewModelComponent, then uses ViewModelComponent to get the actual ViewModel providers. Entry points enable this multi-level component navigation.
Pattern 4: Testing and dependency replacement
Entry points provide clean boundaries for test doubles:
@EntryPoint
@InstallIn(SingletonComponent::class)
interface NetworkEntryPoint {
fun getApiService(): ApiService
}
class NetworkIntegrationTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Test
fun testNetworkCall() {
val apiService = EntryPointAccessors
.fromApplication<NetworkEntryPoint>(context)
.getApiService()
val response = apiService.fetchData()
assertThat(response).isNotNull()
}
}
In tests, you can replace the module that provides ApiService with a test module that provides a mock, and access it through the same entry point interface.
Anti-patterns: When entry points are the wrong tool
Understanding entry points reveals when they're being misused.
Anti-pattern 1: Entry points for constructor-injectable classes
// This is "Bad" - using entry point when constructor injection works
class MyViewModel(
private val context: Context
) : ViewModel() {
private val repository = EntryPointAccessors
.fromApplication<RepositoryEntryPoint>(context)
.getRepository()
fun loadData() {
repository.fetchData()
}
}
// "Good" - use constructor injection
@HiltViewModel
class MyViewModel @Inject constructor(
private val repository: Repository
) : ViewModel() {
fun loadData() {
repository.fetchData()
}
}
ViewModels support @HiltViewModel with constructor injection. Entry points are unnecessary complexity.
Anti-pattern 2: Entry points for inter-component communication
// Bad - using entry points to share state between components
@EntryPoint
@InstallIn(SingletonComponent::class)
interface StateHolderEntryPoint {
fun getStateHolder(): MutableStateHolder
}
class FragmentA : Fragment() {
fun updateState() {
val holder = EntryPointAccessors
.fromApplication<StateHolderEntryPoint>(requireContext())
.getStateHolder()
holder.state = "updated"
}
}
class FragmentB : Fragment() {
fun readState() {
val holder = EntryPointAccessors
.fromApplication<StateHolderEntryPoint>(requireContext())
.getStateHolder()
val state = holder.state
}
}
// Good - inject shared state directly
@Singleton
class StateHolder @Inject constructor() {
var state: String = ""
}
class FragmentA : Fragment() {
@Inject lateinit var stateHolder: StateHolder
fun updateState() {
stateHolder.state = "updated"
}
}
class FragmentB : Fragment() {
@Inject lateinit var stateHolder: StateHolder
fun readState() {
val state = stateHolder.state
}
}
Both fragments can use @AndroidEntryPoint and inject the state holder directly. Entry points add no value here.
Anti-pattern 3: Overusing entry points for convenience
// Bad - creating entry points for every dependency
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AnalyticsEntryPoint {
fun getAnalytics(): Analytics
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface LoggerEntryPoint {
fun getLogger(): Logger
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface NetworkEntryPoint {
fun getNetwork(): NetworkClient
}
// Proliferation of single-method entry points
// Good - combine related dependencies
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AppServicesEntryPoint {
fun getAnalytics(): Analytics
fun getLogger(): Logger
fun getNetwork(): NetworkClient
}
Each entry point adds compile-time overhead (annotation processing, code generation) and runtime overhead (interface implementation, method calls). Grouping related dependencies reduces this overhead.
Performance characteristics: Measuring entry point overhead
Entry points introduce minimal runtime cost but understanding the performance implications helps optimize their usage.
Runtime cost breakdown
Accessing a dependency through an entry point:
val imageLoader = EntryPointAccessors.fromApplication<ImageLoaderEntryPoint>(context)
.getImageLoader()
The cost breakdown:
-
Context unwrapping:
Contexts.getApplication(context)checks if the context is already an Application or needs unwrapping. This is typically 1 to 2instanceofchecks, approximately 5 nanoseconds. -
Component retrieval:
componentManager()returns the cached component manager, a simple field access, effectively free. -
Component unwrapping:
generatedComponent()performs double-checked locking to ensure the component is initialized. If already initialized, this is a volatile read, approximately 5 nanoseconds. -
Type casting:
entryPoint.cast(component)performs a runtime type cast. After JIT compilation, this is effectively free (the JIT can optimize it out if it proves the cast always succeeds). -
Method invocation:
getImageLoader()calls the component's implementation. This is a virtual method call, the same cost as calling any interface method, approximately 2 to 5 nanoseconds. -
Dependency retrieval: The component returns the dependency, either from a cached singleton or by calling a provider. This cost is identical to direct injection.
Total overhead for entry point access: approximately 10 to 20 nanoseconds, plus the cost of creating the dependency (which is the same as direct injection).
For comparison, a single network request takes 50 to 200 milliseconds (50,000,000 to 200,000,000 nanoseconds). Entry point overhead is negligible in practice.
Caching strategies
If calling an entry point repeatedly in a hot path:
// Not pretty good: Suboptimal - entry point lookup on every iteration
fun processImages(urls: List<String>) {
urls.forEach { url ->
val loader = EntryPointAccessors
.fromApplication<ImageLoaderEntryPoint>(context)
.getImageLoader()
loader.load(url)
}
}
// Good
fun processImages(urls: List<String>) {
val loader = EntryPointAccessors
.fromApplication<ImageLoaderEntryPoint>(context)
.getImageLoader()
urls.forEach { url ->
loader.load(url)
}
}
The optimized version performs the entry point lookup once, then reuses the reference. This saves approximately 10 to 20 nanoseconds per iteration, which matters in tight loops processing thousands of items.
For dependencies accessed from multiple methods, cache at the class level:
class CustomImageView(...) : AppCompatImageView(...) {
private val imageLoader: ImageLoader by lazy {
if (!isInEditMode) {
EntryPointAccessors
.fromApplication<ImageLoaderEntryPoint>(context)
.getImageLoader()
} else {
null // Preview mode doesn't have Hilt
}
}
fun loadImage(url: String) {
imageLoader?.load(url)?.into(this)
}
fun clearImage() {
imageLoader?.clear(this)
}
}
The lazy delegate ensures the entry point lookup happens once, on first access. Subsequent calls to loadImage() and clearImage() use the cached reference.
Component creation cost
Entry points don't affect component creation cost. Components are created lazily when first accessed, whether through injection or entry points. The component manager uses double-checked locking to ensure single initialization:
public Object generatedComponent() {
if (component == null) {
synchronized (componentLock) {
if (component == null) {
component = componentCreator.get();
}
}
}
return component;
}
The first access triggers component creation (expensive, typically 1 to 10 milliseconds depending on the dependency graph size). Subsequent accesses return the cached component (free).
Entry points and direct injection share the same component instance, so the creation cost is identical.
Testing considerations: Entry points in test environments
Entry points interact with Hilt's testing infrastructure in specific ways worth understanding.
Test entry points
Hilt provides @EarlyEntryPoint for testing scenarios:
@EarlyEntryPoint
@InstallIn(SingletonComponent::class)
interface TestDatabaseEntryPoint {
fun getDatabase(): Database
}
@HiltAndroidTest
class DatabaseTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Before
fun setup() {
val database = EarlyEntryPoints.get(
ApplicationProvider.getApplicationContext(),
TestDatabaseEntryPoint::class.java
).getDatabase()
database.clearAllTables() // Clear before each test
}
}
@EarlyEntryPoint uses RUNTIME retention instead of CLASS retention, allowing access before Application.onCreate() completes. This is useful in tests where you need to prepare state before the application fully initializes.
Replacing entry point implementations
Entry points access whatever dependencies are in the component. To replace dependencies in tests:
@Module
@InstallIn(SingletonComponent::class)
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [ProductionNetworkModule::class]
)
object TestNetworkModule {
@Provides
@Singleton
fun provideApiService(): ApiService = FakeApiService()
}
@HiltAndroidTest
class NetworkIntegrationTest {
@Test
fun testWithFakeNetwork() {
val apiService = EntryPointAccessors
.fromApplication<NetworkEntryPoint>(context)
.getApiService()
// apiService is FakeApiService from TestNetworkModule
val response = apiService.fetchData()
assertThat(response).isEqualTo(fakeData)
}
}
The @TestInstallIn annotation replaces ProductionNetworkModule with TestNetworkModule, changing what the entry point returns without modifying the entry point itself.
Conclusion
Entry points bridge the gap between Hilt's dependency graph and code that Hilt doesn't control through a remarkably simple design: entry points are interfaces that generated components implement. The annotation processor validates at compile time that entry points are installed in the correct components, generates aggregated metadata for multi-module support, and makes components implement entry point interfaces. At runtime, EntryPoints.get() performs a type-safe cast to the entry point interface, giving access to the component's provision methods.
The internal implementation reveals important design decisions. Entry points use CLASS retention for compile-time processing, minimizing runtime overhead. Package-private wrapping allows entry points to remain internal while being accessible to generated components. The aggregation pattern enables incremental compilation across module boundaries. Component managers provide lazy initialization with thread safety guarantees. The type-cast based implementation has negligible runtime cost compared to direct injection.
Understanding these internals helps you make better architectural decisions. Use entry points when Hilt doesn't control object creation: custom Views, third-party libraries, WorkManager workers, system-instantiated components. Avoid entry points when constructor or field injection works: activities, fragments, ViewModels, services annotated with @AndroidEntryPoint. Cache entry point references when accessing repeatedly in hot paths. Group related dependencies in a single entry point interface rather than creating many single-method entry points. Whether you're integrating custom views, bridging third-party libraries, or implementing framework factories, entry points provide a type-safe, performant solution to the pull-based dependency access problem.
As always, happy coding!
β Jaewoong
π‘ Tips With Code
1. Custom Convention Plugins for Scalable Multi-Module Projects
When building large Android applications with dozens of modules, managing Gradle configuration becomes a nightmare. You'll find yourself copying the same configuration blocks across build.gradle files, and when you need to update a dependency or SDK version, you have to touch every single module. This pattern is essential for enterprise applications like banking apps, e-commerce platforms, or any project with 10+ modules where consistency and maintainability are critical.
First, create a separate Gradle module for your build logic in build-logic/:
import com.android.build.gradle.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
class AndroidLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.android.library")
apply("org.jetbrains.kotlin.android")
}
extensions.configure<LibraryExtension> {
compileSdk = 36
defaultConfig {
minSdk = 23
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
freeCompilerArgs += listOf(
"-Xexplicit-api=strict",
"-opt-in=kotlin.RequiresOptIn",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
)
}
buildFeatures {
buildConfig = true
}
}
}
}
}
For Compose-specific modules, create a composition plugin:
class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
// First apply base library convention
pluginManager.apply("myapp.android.library")
// Add Compose-specific configuration
extensions.configure<LibraryExtension> {
buildFeatures {
compose = true
}
}
// Add Compose dependencies
dependencies {
val composeBom = libs.findLibrary("androidx-compose-bom").get()
add("implementation", platform(composeBom))
add("implementation", libs.findLibrary("androidx-compose-ui").get())
add("implementation", libs.findLibrary("androidx-compose-material3").get())
add("implementation", libs.findLibrary("androidx-compose-ui-tooling-preview").get())
add("debugImplementation", libs.findLibrary("androidx-compose-ui-tooling").get())
}
}
}
}
Register your plugins in build-logic/build.gradle.kts:
gradlePlugin {
plugins {
register("androidLibrary") {
id = "myapp.android.library"
implementationClass = "AndroidLibraryConventionPlugin"
}
register("androidLibraryCompose") {
id = "myapp.android.library.compose"
implementationClass = "AndroidLibraryComposeConventionPlugin"
}
register("androidFeature") {
id = "myapp.android.feature"
implementationClass = "AndroidFeatureConventionPlugin"
}
}
}
Now your module's build.gradle.kts becomes incredibly simple:
// feature/home/build.gradle.kts
plugins {
id("myapp.android.feature")
id("myapp.android.hilt.compose")
}
android {
namespace = "com.xperiventure.myapp.feature.home"
}
dependencies {
implementation(projects.core.designsystem)
implementation(projects.core.data)
implementation(projects.core.navigation)
}
This approach provides centralized configuration management where you update SDK versions, compile options, and dependencies in one place, eliminates copy-paste errors across modules, enforces consistency in build configuration, and makes onboarding new developers easier since conventions are explicit and documented. The pattern scales from 10 to 100+ modules without increasing complexity, and you can compose plugins together (like combining android.feature with hilt.compose).
Compare this to the traditional approach where every module duplicates configuration:
// Without convention plugins - REPETITIVE AND ERROR-PRONE
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
compileSdk = 36
defaultConfig {
minSdk = 23
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
freeCompilerArgs += listOf(/* ... */)
}
buildFeatures {
compose = true
buildConfig = true
}
}
// Repeat this in EVERY module... nightmare!
For feature modules specifically, you can create an even more specialized convention:
// build-logic/src/main/kotlin/AndroidFeatureConventionPlugin.kt
class AndroidFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
// Apply all feature-module conventions at once
with(pluginManager) {
apply("myapp.android.library.compose")
apply("myapp.android.hilt")
}
// Add dependencies that ALL features need
dependencies {
add("implementation", project(":core:designsystem"))
add("implementation", project(":core:navigation"))
add("implementation", project(":core:model"))
add("implementation", libs.findLibrary("androidx-lifecycle-viewmodel").get())
add("implementation", libs.findLibrary("androidx-navigation-compose").get())
add("implementation", libs.findLibrary("kotlinx-coroutines-android").get())
}
}
}
}
πΌ Job Posting
Google, Software Engineer III, Mobile, Android (UK, London)
Job Posting
- Minimum requirements: Bachelorβs degree (or equivalent), + ~2 years software development experience (or 1 yr with advanced degree), and β₯2 years of Android application development experience.
- Preferred skills: familiarity with Android platform/ecosystem; ability to proactively identify system improvement opportunities; good communication with stakeholders; readiness to take full ownership for outcomes.
- Responsibilities: design, develop, test, deploy, maintain, and enhance Android software (apps or systems).
- Additional duties: review and provide feedback on code from other engineers; contribute to documentation and educational content; debug, triage, and fix product or system issues (including analyzing hardware, network or service-level impacts).
- Collaboration and flexibility: work across full stack (mobile, UI, backend), collaborate with cross-functional teams, and adapt to evolving projects β reflecting Googleβs need for versatile engineers who can tackle diverse, large-scale challenges.
Like what you see?
Subscribe to Dove Letter to get weekly insights about Android and Kotlin development, plus access to exclusive content and discussions.