Let’s Complicate Compose Previews

A look at compose previews with DI and ViewModels.

Kerry Bisset
12 min readMay 20, 2024

--

What happens when the examples look like homework, but the implementation feels more like exams? This is a common dilemma for developers exploring Jetpack Compose, particularly when leveraging its Preview feature. While many tutorials and guides showcase Compose Preview with straightforward examples, real-world applications often present much more complexity. How does Compose Preview hold up when faced with intricate UI components, multiple dependencies, and sophisticated state management? This article explores these challenges, exploring advanced strategies for managing Compose Preview in complex scenarios.

Jetpack Compose has revolutionized Android UI development with its declarative approach, allowing developers to build interfaces with less code and greater clarity than ever before. However, as developers push the boundaries of what’s possible with Compose, they encounter the limitations of its Preview feature — limitations that aren’t immediately apparent in the basic tutorials.

From integrating ViewModel to managing dependency injection and optimizing compilation, numerous advanced topics need addressing to fully leverage Compose Preview in a professional development environment. The strategic organization of previews within a project’s source set is a crucial aspect that can significantly impact the maintainability and efficiency of your development process, and this article also discusses this aspect.

The Basics of Compose Preview

Before venturing into the complexities of handling advanced scenarios with Compose Preview, it’s important to understand its fundamental operations and how it fits into the Jetpack Compose ecosystem. Compose Preview is not just a feature — it’s an essential part of the development cycle that bridges the gap between design and implementation by providing a real-time graphical representation of Composable functions.

What is Compose Preview?

Compose Preview is a feature integrated within the Android Studio IDE(or IntelliJ*), allowing developers to visualize @Composable functions directly in the editor. This tool renders the UI components defined in your Composables without running the entire application, offering a quick and efficient way to check the look and feel of UI elements.

How Does Compose Preview Work?

To utilize Compose Preview, developers annotate their Composable functions with the @Preview annotation. This simple yet powerful annotation instructs Android Studio to render the UI components within the IDE, which can display different themes, device configurations, or even custom-parameterized data. Here's a basic example:

@Preview(showBackground = true, name = "Default Preview")
@Composable
fun DefaultPreview() {
MyAppTheme {
Greeting("Android")
}
}

In this snippet, DefaultPreview provides a live preview of the Greeting composable within the Android Studio environment, using the default settings specified in MyAppTheme.

Benefits of Using Compose Preview

  1. Rapid Feedback Cycle: Compose Preview significantly speeds up UI development by rendering changes in real-time, which is particularly useful during the early stages of component design.
  2. Ease of Use: By continually eliminating the need to deploy the application to a device or emulator, Compose Preview saves time and reduces the barrier to experimenting with new UI ideas or adjustments.
  3. Design Versatility: Developers can quickly preview how UI components adapt to different configurations, such as screen sizes, orientations, and themes, ensuring responsiveness and consistency across diverse devices.

Advanced Preview Management

As our projects using Jetpack Compose grow in scale and complexity, the straightforward approach to using Compose Preview might not suffice. Advanced scenarios often require integrating architecture components, such as view models, or managing dependencies not inherently supported by the basic preview setup. This section delves into the strategies and techniques for enhancing Compose Preview to effectively handle these more complex requirements.

Preview Parameters

Customizing previews with parameters allows for a flexible and dynamic approach to UI development. You can simulate different states and data models directly within the Android Studio environment by parameterizing your previews. This is particularly useful when you need to visualize how a UI component behaves under various conditions without the overhead of running the entire application.

Integration with ViewModel

ViewModels manage UI-related data in a lifecycle-conscious way. Integrating ViewModel with Compose Preview can be challenging due to the required lifecycle awareness and the required separation of concerns. Let’s explore techniques to mock or provide ViewModel instances in previews, ensuring that developers can accurately simulate UI states managed by ViewModel without complicating the development environment.

Dependency Injection

Dependency injection (DI) is a standard practice for managing code dependencies in Android and other platforms, promoting modularity and testability. However, DI can complicate previews because dependencies typically need to be initialized or mocked. We will look at methods for incorporating DI frameworks like Hilt or Koin(I will be using Koin) into your Compose Preview setups, which allows for more maintainable and scalable code.

Integrating ViewModel with Compose Preview using Koin

Managing state and dependencies effectively becomes crucial in more complex Jetpack Compose applications. Integrating the ViewModel with Compose Preview is a common challenge, especially when using a dependency injection framework like Koin. This section will walk through an example of achieving this, ensuring that previews accurately reflect the app's dynamic state without the overhead of a full application run.

Understanding the ComposableViewModel Function

The ComposableViewModel composable function is designed to facilitate the instantiation and lifecycle management of a ComposeViewModel within a composable hierarchy. This function ensures that each ViewModel instance is properly scoped to the current composition and disposed of when no longer needed. Here's how it integrates with the app's architecture:

  1. ViewModel Instantiation: The function accepts a factory lambda that creates a ViewModel instance. This factory is typically tied to your DI framework, allowing Koin to provide the ViewModel instance.
  2. Lifecycle Management: The lifecycle of the ViewModel is managed through the remember and DisposableEffect functions. The remember function ensures that the ViewModel persists across recompositions unless a key changes. DisposableEffect ensures that resources are cleaned up correctly, using the clearViewModel method for disposal.
  3. ViewModel Usage: The ViewModel is then passed to a content lambda, where it can drive the UI. This pattern ensures that the ViewModel’s lifecycle is tightly integrated with the UI’s lifecycle, reducing the risk of memory leaks or stale data.

Here are several articles about it: Introduction, Usage, State Retention, Koin.

Modified ComposeViewModel

/**
* A base view model designed to wrap [Composable] functions with associated business logic.
*
* This class provides core utilities and structures to manage coroutine lifecycles within the
* scope of a ViewModel. It ensures that the coroutines are automatically cancelled and cleaned up
* when the ViewModel is no longer in use.
*/
abstract class ComposeViewModel : KoinComponent {

/**
* Represents a [SupervisorJob] that oversees all coroutine jobs initiated by this ViewModel.
* Utilizing a supervisor allows child jobs to fail independently without affecting others.
*/
protected val viewModelSupervisor = SupervisorJob()

/**
* A coroutine scope specifically for this ViewModel, determined by [viewModelSupervisor].
* All coroutines launched within this ViewModel should use this scope to ensure they are
* tied to the ViewModel's lifecycle.
*/
protected val viewModelScope = CoroutineScope(viewModelSupervisor)

/**
* Retrieves the logger instance from the Koin container.
*/
protected val logger: ILogger
get() {
return get<ILogger>()
}

protected val dispatcher: IDispatcherProvider
get() {
return get<IDispatcherProvider>()
}

/**
* Abstract function that should be overridden by subclasses to clear or release resources
* and jobs when the ViewModel is about to be disposed of.
*
* This function gets called when [clearViewModel] is executed, before the supervisor gets cancelled.
*/
abstract suspend fun onClear()

/**
* Handles the process of cleaning up and releasing all resources tied to the ViewModel.
* It first triggers the [onClear] method and, upon completion, cancels the [viewModelSupervisor]
* which in turn cancels all active jobs under its jurisdiction.
*/
fun clearViewModel() {
viewModelScope.launch {
onClear()
}.invokeOnCompletion {
viewModelSupervisor.cancel("Compose View Model Job Cancelled")
}
}
}

This implementation of the Composable View Model has been altered to be a KoinComponent. This version allows protected access to a logger and dispatcher, which I used frequently.

Code Walkthrough

Let’s consider two ViewModel examples: MainViewModel and SecondViewModel, each tailored to different UI parts and potentially having different dependencies provided by Koin.

class MainViewModel : ComposeViewModel() {

// Get String Provider from Koin
private val stringProvider = get<IStringProvider>()
val test = mutableStateOf(stringProvider.string)


override suspend fun onClear() {
// Not Implemented
}
}
  • The MainViewModel leverages an IStringProvider to obtain a string, which it exposes via a mutableStateOf. This ViewModel is simple and focuses on displaying a greeting.
class SecondViewModel : ComposeViewModel() {

// Get String Provider from Koin
private val stringProvider = get<IStringProvider>(named("Second"))

internal val display = mutableStateOf(stringProvider.string)

override suspend fun onClear() {
// Do Nothing
}
}
  • SecondViewModel also uses an IStringProvider, but it gets a differently named instance, demonstrating how to handle multiple configurations or variants of a dependency.

Both ViewModels extend ComposeViewModel, which contains the core logic for managing coroutine lifecycles and DI components like logging and dispatchers. This base class ensures that all coroutine jobs are canceled when the ViewModel is disposed of, preventing leaks and ensuring that the UI components are always up-to-date with the latest data state.

Integration in Composables

In the MainActivity, the ViewModels are instantiated and provided to the composables Greeting and SecondItem via the ComposableViewModel function. This setup demonstrates how the ViewModel can be dynamically provided and managed within the composable functions:

@Composable
fun Greeting(modifier: Modifier = Modifier) {
val koin = getKoin()
ComposableViewModel(factory = { koin.get<MainViewModel>() }) {
GreetingView(viewModel = it)
}
}

@Composable
fun SecondItem() {
val koin = getKoin()
ComposableViewModel(factory = { koin.get<SecondViewModel>() }) {
SecondView(viewModel = it)
}
}

Previewing with ViewModel

For previewing these composables with their respective ViewModels, PreviewParameterProvider classes can be used to supply mock data. This approach allows developers to see realistic previews without requiring a full app context or live data:

@Preview(showBackground = true)
@Composable
internal fun GreetingView(@PreviewParameter(MainViewModelParameterProvider::class) viewModel: MainViewModel) {
Text(
text = "Hello ${viewModel.test.value}!",
)
}

@Preview(showBackground = true)
@Composable
internal fun SecondView(@PreviewParameter(SecondViewModelParameterProvider::class) viewModel: SecondViewModel) {
Text(
text = "Hello ${viewModel.display.value}!",
)
}

Preview Parameter Providers with Koin Integration

To facilitate the integration of ViewModels with Jetpack Compose Previews, especially when using a dependency injection framework like Koin, it's crucial to set up an environment where dependencies are appropriately initialized even in preview mode. This setup ensures that the previews reflect realistic application states without needing a full context. Here, we delve into creating a KoinPreviewParameterProvider that initializes Koin and supplies ViewModel instances for previews.

/**
* Singleton object responsible for initializing and managing a KoinApplication specifically for Compose Previews.
* This object ensures that Koin is started with the necessary modules before any ViewModel in previews is requested.
* This setup is crucial for integrating dependency injection within the Jetpack Compose Preview environment.
*/
private object PreviewKoin {
// Holds the singleton instance of KoinApplication.
private var _koin: KoinApplication? = null

/**
* Starts the Koin application with specified modules if it has not already been started.
* This method checks if the KoinApplication instance is null and, if so, initializes it with
* the necessary modules, thereby ensuring Koin is setup correctly before any ViewModel is created.
*/
private fun startKoin() {
if (_koin == null) {
val app = koinApplication {
// Includes the modules needed for previewing ViewModels which potentially need DI.
modules(appModule)
}
// Start the Koin application using the default context provided by KoinPlatformTools.
KoinPlatformTools.defaultContext().startKoin(app)
_koin = app
}
}

// Initialize Koin when the object is created.
init {
startKoin()
}
}

/**
* Abstract base class for PreviewParameterProviders that integrates Koin dependency injection.
* This class ensures that Koin is initialized and ready to inject dependencies before any ViewModel is provided.
* It leverages the singleton PreviewKoin to manage Koin's lifecycle and to guarantee that DI is functional during previews.
*
* @param T The type of ViewModel this provider will supply to previews. Must be a subclass of ViewModel.
*/
abstract class KoinPreviewParameterProvider<T> : PreviewParameterProvider<T> {
init {
// Initialize the Koin application on the creation of any instance of this class.
// This ensures that Koin is ready to provide dependencies for the ViewModel instances used in previews.
PreviewKoin
}
}

In this setup, PreviewKoin acts as the initializer for Koin, encapsulating the startup logic to ensure it's executed only once. This pattern is vital for performance and correctness, avoiding multiple initializations, which could lead to inconsistent preview behavior.

val appModule = module {

single { DispatcherProvider() } bind IDispatcherProvider::class

single { AndroidLogger() } bind ILogger::class

single { TestStringProvider() } bind IStringProvider::class

single(named("Second")) {
object : IStringProvider {
override val string: String
get() = "Second"

}
} bind IStringProvider::class


factory { MainViewModel() }

factory { SecondViewModel() }
}

ViewModel Parameter Providers

The specific ViewModel providers like MainViewModelParameterProvider and SecondViewModelParameterProvider are then inherited from KoinPreviewParameterProvider. They provide sequences of ViewModel instances prepared with Koin's dependency management:

class MainViewModelParameterProvider : KoinPreviewParameterProvider<MainViewModel>() {
override val values: Sequence<MainViewModel>
get() {
return sequenceOf(MainViewModel())
}
}

class SecondViewModelParameterProvider : KoinPreviewParameterProvider<SecondViewModel>() {
override val values: Sequence<SecondViewModel>
get() {
return sequenceOf(SecondViewModel())
}
}

Composable Functions Setup

Greeting Function:

@Composable
fun Greeting(modifier: Modifier = Modifier) {
val koin = getKoin()
ComposableViewModel(factory = { koin.get<MainViewModel>() }) {
GreetingView(viewModel = it)
}
}

@Preview(showBackground = true)
@Composable
internal fun GreetingView(@PreviewParameter(MainViewModelParameterProvider::class) viewModel: MainViewModel) {
Text(
text = "Hello ${viewModel.test.value}!",
)
}
  • This function utilizes ComposableViewModel to fetch and manage an instance of MainViewModel using Koin's dependency resolution.
  • Inside Greeting, the MainViewModel instance is then passed to GreetingView composable, which uses the test.value to display "Hello Test!".
  • The text “Hello Test!” indicates that the MainViewModel is correctly fetching its dependency (IStringProvider), which provides the string “Test.”

SecondItem Function:

@Composable
fun SecondItem() {
val koin = getKoin()
ComposableViewModel(factory = { koin.get<SecondViewModel>() }) {
SecondView(viewModel = it)
}
}

@Preview(showBackground = true)
@Composable
internal fun SecondView(@PreviewParameter(SecondViewModelParameterProvider::class) viewModel: SecondViewModel) {
Text(
text = "Hello ${viewModel.display.value}!",
)
}
  • Like the Greeting function, SecondItem uses ComposableViewModel to obtain an instance of SecondViewModel.
  • The instance of SecondViewModel is then provided to SecondView, which displays "Hello Second!" using the display.value.
  • The displayed text “Hello Second!” confirms that the SecondViewModel is also retrieving its dependency correctly (IStringProvider named "Second") which provides the string "Second".

Outcome and Interpretation

  • Correct Dependency Injection: The successful display of “Hello Test!” and “Hello Second!” in the preview pane confirms that Koin is properly initialized and functioning within Jetpack Compose’s preview environment. Each ViewModel is receiving its dependencies as expected, showcasing the effectiveness of integrating Koin with Compose Preview.
  • Effective ViewModel Management: ComposableViewModel indicates a management strategy for ViewModel lifecycles within composable functions, ensuring that ViewModel instances are tied to the composable's lifecycle. This setup helps avoid memory leaks and ensures the UI components are updated appropriately.
  • Preview Utility: The previews demonstrate how you can visualize different ViewModel states directly within the IDE, enhancing development efficiency by providing immediate feedback on UI and logic changes without running the entire application.

Reflecting on Compose Preview and Development Practices

As powerful and useful as Compose Preview is for Android UI development, it introduces some complexities and potential pitfalls that need careful consideration. Integrating preview support within the codebase can lead to challenges related to build configuration and separation of concerns.

Challenges with PreviewParameters

PreviewParameter classes, useful for enhancing Compose Previews’ functionality, get compiled into the main codebase. This is often seen as clutter, especially since these classes are used only during development and are unnecessary in production.

Solutions:

  • ProGuard: A common approach is using tools like ProGuard or R8 to remove these classes from the release builds. However, configuring ProGuard correctly requires additional knowledge and setup, which might not be straightforward for all developers.
  • Debug Source Set: Another strategy is to place these classes in the debug source set. This ensures that the classes are excluded from the release builds automatically. The downside is that the classes are separated from the main implementation files, which might hinder some developers who prefer to keep related code closely together for easier navigation and maintenance.

[Thoughts on] Mocking Information in Previews

Mocking libraries like MockK can be highly beneficial to ensure that Compose Previews are lightweight and isolated from the actual implementation details. Mocking dependencies for previews can significantly speed up their loading and rendering time.

Challenges:

  • Integrating a mocking library directly within the main project configuration, even if scoped only to debug builds, can feel inappropriate. It blurs the lines between test setup and actual application logic.

Solutions:

  • Another approach is to use conditional dependencies that ensure mocking libraries are included only in debug builds. This can be configured in the build system to prevent any test-related dependencies from leaking into the release builds. The downside, like above, is that it separates the preview from the implementation in the main source set.

Notes:

  • I can understand that you cannot place previews in the test module.
  • Using mockk worked well and was very flexible in changing the UI, with the caveat of interactions becoming limited and needing to define every return.

--

--