Skip to main content

Using Firebase Analytics with Jetpack Compose

Introduction #

In this blog post I’ve demonstrated how you can use Firebase Analytics with Jetpack compose in your Android projects to track user behavior and common events. I’ve covered creating an event interface and implementing the interface for different build flavors, so you can utilize your development process in a way you like. I’ve showed how you can create a custom CompositionLocal and call your event logging from Composable functions, and there is a helper class to reduce boilerplate when using it. Overall, this approach can greatly enhance the user experience of your app by providing valuable insights into user behavior.

Note: This post requires basic information about Hilt, Firebase Analytics and Compose.

Firebase Analytics & Jetpack Compose #

Firebase Analytics is a powerful tool for tracking user behavior. Using the data it provides, you can improve the user experience of your app. You can track screen views, button clicks, dropdown selections, and many other events depending on your app’s needs.

If you come from the older days of Android development where we created an activity for every page, Firebase Analytics could automatically send screen view events. However, in fragments and Jetpack Compose, you need to manually send a “screen view” event. I will show you how to do it in Jetpack Compose shortly.

Add Firebase to Your Project #

dependency {
  implementation(platform("com.google.firebase:firebase-bom:32.2.0"))
  // When using the BoM, you don't specify versions in Firebase library dependencies

  // Add the dependency for the Firebase SDK for Google Analytics
  implementation("com.google.firebase:firebase-analytics-ktx")
}

Create Event Class #

First we create a data class to hold information about events, lets call it AnalyticsEvent

data class AnalyticsEvent(
    val type: String,
    val extras: List<Param> = emptyList(),
) {
    // Standard analytics types.
    object Types {
        const val SCREEN_VIEW = "screen_view" // (extras: SCREEN_NAME)
        const val SELECT_ITEM = "select_item"
        const val BUTTON_CLICK = "button_click"
        const val SUBMIT_RATING = "submit_rating"
    }

    /**
     * A key-value pair used to supply extra context to an 
     * analytics event.
     *
     * @param key - the parameter key. Wherever possible use 
     * one of the standard `ParamKeys`, however, if no suitable 
     * key is available you can define your own as long as it is 
     * configured in your backend analytics system (for example, 
     * by creating a Firebase Analytics custom parameter).
     *
     * @param value - the parameter value.
     */
    data class Param(val key: String, val value: String)

    // Standard parameter keys.
    object ParamKeys {
        const val SCREEN_NAME = "screen_name"
        const val BUTTON_ID = "button_id"
        const val ITEM_ID = "item_id"
        const val ITEM_NAME = "item_name"
        const val RATING_TYPE = "rating_type"
        const val RATING_CONTENT = "rating_content"
    }
}

Create Analytics Interface #

Then we create an interface for processing the events;

interface AnalyticsHelper {
    fun logEvent(event: AnalyticsEvent)
}

Later we will implement this interface for different flavors. This will allow us to differentiate the action on each flavor, such as logging or sending it to firebase.

Analytics Interface Implementations #

NoOperationAnalyticsHelper; this implementation does nothing, it is useful for testing and previews

class NoOpAnalyticsHelper : AnalyticsHelper {
    override fun logEvent(event: AnalyticsEvent) = Unit
}

StubAnalyticsHelper.kt; this implementation is used in development phase, it just logs the event details and nothing else

private const val TAG = "StubAnalyticsHelper"

/**
 * An implementation of AnalyticsHelper just writes the events 
 * to logcat. Used in builds where no analytics events 
 * should be sent to a backend.
 */
@Singleton
class StubAnalyticsHelper @Inject constructor() : AnalyticsHelper {
    override fun logEvent(event: AnalyticsEvent) {
        Log.d(TAG, "Received analytics event: $event")
    }
}

And finally the firebase analytics implementation

/**
 * Implementation of `AnalyticsHelper` which logs events 
 * to a Firebase backend.
 */
class FirebaseAnalyticsHelper @Inject constructor(
    private val firebaseAnalytics: FirebaseAnalytics,
) : AnalyticsHelper {

    override fun logEvent(event: AnalyticsEvent) {
        firebaseAnalytics.logEvent(event.type) {
            for (extra in event.extras) {
                // Truncate parameter keys and values 
                // according to firebase maximum length values.
                param(
                    key = extra.key.take(40),
                    value = extra.value.take(100),
                )
            }
        }
    }
}

Up until now we have created an analytics event data class, an interface to send events and three implementation of the interface.

Directory Structure & Hilt Modules #

Now we will add hilt modules to use these implementations.

I have mentioned about different flavors. I usually use two or more flavors when i develop an android application. It helps me to use different configuration for development/test and production environments. In this case I will use it to ‘inject’ StubAnalyticsHelper on dev and FirebaseAnalyticsHelper on production flavor.
Here is how the directory layout will look;

└── app
    └── src
         ├── main
         │    └── com.example.yourapp.analytics
         │          ├── AnalyticsEvent.kt
         │          ├── AnayticsHelper.kt
         │          ├── NoOpAnayticsHelper.kt
         │          ├── StubAnayticsHelper.kt
         │          └── UIHelpers.kt
         ├── dev
         │    └── com.example.yourapp.analytics
         │          └── AnayticsModule.kt
         └── prod
              └── com.example.yourapp.analytics
                    ├── AnayticsModule.kt
					└── FirebaseAnalyticsHelper.kt

in the dev folder use this analytics module;

@Module
@InstallIn(SingletonComponent::class)
abstract class AnalyticsModule {
    @Binds
    abstract fun bindsAnalyticsHelper(
        analyticsHelperImpl: StubAnalyticsHelper
    ): AnalyticsHelper
}

and in the prod folder use this analytics module;

@Module
@InstallIn(SingletonComponent::class)
abstract class AnalyticsModule {
    @Binds
    abstract fun bindsAnalyticsHelper(
        analyticsHelperImpl: FirebaseAnalyticsHelper
    ): AnalyticsHelper

    companion object {
        @Provides
        @Singleton
        fun provideFirebaseAnalytics(): FirebaseAnalytics { 
            return Firebase.analytics 
        }
    }
}

These are the same file (same name) in different folders. When use select the build variant with dev flavor it will use the one in the dev folder and if you select for prod it will use the firebase analytics one.

At this point we can inject Analytics helper anywhere we want and it will use the respected implementation according to build variant.

If you want to use the AnalyticsHelper interface in only ViewModels you are good to go, but if you want to use it in Compose functions follow along.

CompositionLocalProvider #

To be able to call logging events on AnalyticsHelper we will define a new composition local. We will set it in the creation of the compose and will be able to call it in where ever you want.

Lets create the custom composition local, create a file named UIHelpers.kt and write the following declaration.

/**
 * Global key used to obtain access to the AnalyticsHelper 
 * through a CompositionLocal.
 */
val LocalAnalyticsHelper = staticCompositionLocalOf<AnalyticsHelper> {
    // Provide a default AnalyticsHelper which does nothing. 
    // This is so that tests and previews do not have to provide one. 
    // For real app builds provide a different implementation.
    NoOpAnalyticsHelper()
}

Now that we have our custom CompositionLocal lets use it;

Inject the AnalyticsHelper in MainActivity and set the composition local with it

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
  
    @Inject
    lateinit var analyticsHelper: AnalyticsHelper
  
    override fun onCreate(savedInstanceState: Bundle?) {
        setContent {
            CompositionLocalProvider(
                LocalAnalyticsHelper provides analyticsHelper,
            ) {
                YourAppTheme {
                // Your Application's Compose code
                }
            }
        }
    }
}

Now we can call it from any composable function if the composition tree is under this declaration.

@Composable
fun TestScreen() {
    val analyticsHelper = LocalAnalyticsHelper.current
    analyticsHelper.logEvent(
        AnalyticsEvent(
            type = AnalyticsEvent.Types.SCREEN_VIEW,
            extras = listOf(
                Param(AnalyticsEvent.ParamKeys.SCREEN_NAME, screenName),
            ),
        ),
    )
}

Helper class for logging #

This is really cool to be able to call events in any compose function, but it look a bit boilerplate just to log screen view. Lets create another file to host our event extensions.

/**
 * Classes and functions associated with analytics events for the UI.
 */
fun AnalyticsHelper.logScreenView(screenName: String) {
    logEvent(
        AnalyticsEvent(
            type = Types.SCREEN_VIEW,
            extras = listOf(
                Param(ParamKeys.SCREEN_NAME, screenName),
            ),
        ),
    )
}

fun AnalyticsHelper.buttonClick(screenName: String, buttonId: String) {
    logEvent(
        AnalyticsEvent(
            type = Types.BUTTON_CLICK,
            extras = listOf(
                Param(ParamKeys.SCREEN_NAME, screenName),
                Param(ParamKeys.BUTTON_ID, buttonId),
            ),
        ),
    )
}

/**
 * A side-effect which records a screen view event.
 */
@Composable
fun TrackScreenViewEvent(
    screenName: String,
    analyticsHelper: AnalyticsHelper = LocalAnalyticsHelper.current,
) = DisposableEffect(Unit) {
    analyticsHelper.logScreenView(screenName)
    onDispose {}
}

These are some of the basic events. With this code you can send screen view events like this;

Sending Screen View Events #

@Composable
fun TestScreen() {
    TrackScreenViewEvent(screenName = "TestScreen")

    // for button click events
    val analyticsHelper = LocalAnalyticsHelper.current
    Button(onClick = {
        analyticsHelper.buttonClick("TestScreen", "test_button")
    }) {
        Text(text = "Send Event")
    }
}

In conclusion, using Firebase Analytics with Jetpack Compose can greatly enhance your app’s user experience by providing valuable insights into user behavior.

This approach of using an interface and different implementations based on build flavor can be used to implement other logging tools or analytics libraries in your Android application. By following the same pattern, you can easily switch between different implementations of the interface based on your needs or build flavor. This can be especially useful in cases where you want to use different logging or analytics tools for different environments or user groups.

Thanks for taking the time to read this blog post. I hope you found it helpful and informative. If you have any feedback or questions, please don’t hesitate to reach out to me. You can contact me directly through my website or social media profiles, which are linked in my bio.

Reference #

You can find the implementation for multimodule projects in now-in-android repository.