Android Friday: Let Activities encapsulate their creation

stone-doors-within-doors.png

Opening screens from other screens in Android apps is straight forward: create an Intent object, stuff some parameters into it, use it to launch the other Activity, and dig out the passed parameters at the other end.

Unfortunately, if you blindly follow Googles standard examples of how to do this, you will end up sprinkling potential bugs around your codebase. Let’s look at how to tighten it up a bit.

In basic Android apps, each distinct screen you see is usually backed by an Activity class. In our case, we have ProfileActivity backing a profile screen:

package tknilsson.com.example.feature

import android.support.v7.app.AppCompatActivity
import android.os.Bundle

class ProfileActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_profile)
    }
}

How do you open this screen/Activity from other places in your app? Well, an Activity can be started via Intents, which cause the operating system to create and start the target Activity.

So, launching an Activity (in our case, launching ProfileActivity from MainActivity) looks like this:

package tknilsson.com.example.feature

import android.content.Intent
import android.support.v7.app.AppCompatActivity
import android.os.Bundle

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val intent = Intent(this, ProfileActivity::class.java)
        startActivity(intent)
    }
}

Sometimes these intents include parameters that we need to pass to the target Activity, for instance:

val intent = Intent(this, ProfileActivity::class.java)
intent.putExtra("profileId", "thomasId4321")
intent.putExtra("hasSubscription", true)
startActivity(intent)

Once launched by an Intent, the ProfileActivity then has to extract its expected parameters from the intent that launched it:

package tknilsson.com.example.feature

import android.support.v7.app.AppCompatActivity
import android.os.Bundle

class ProfileActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_profile)

        val profileId = intent.extras.getString("profileId")
        val hasSubscription = intent.extras.getBoolean("hasSubscription")

        initProfileView(profileId, hasSubscription)
    }

    private fun initProfileView(profileId: String?, hasSubscription: Boolean) {
        // TODO Not implemented for this example
    }
}

Now we have two magical names defined as Strings on each end: “profileId” and “hasSubscription”.

If these parameter names are changed in either Activity, but not both places, then the project will still compile but that parameter will not be passed correctly to the target Activity.

We can avoid this potential bug, though: let the ProfileActivity define and expose the name of the parameters it expects:

package tknilsson.com.example.feature

import android.support.v7.app.AppCompatActivity
import android.os.Bundle

class ProfileActivity : AppCompatActivity() {

    companion object { // Basically static fields, when you read it with Java eyes
        val PARAMETER_KEY_PROFILE_ID = "profileId"
        val PARAMETER_KEY_HAS_SUBSCRIPTION = "hasSubscription"
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_profile)

        val profileId = intent.extras.getString(PARAMETER_KEY_PROFILE_ID)
        val hasSubscription = intent.extras.getBoolean(PARAMETER_KEY_HAS_SUBSCRIPTION)

        initProfileView(profileId, hasSubscription)
    }

    private fun initProfileView(profileId: String?, hasSubscription: Boolean) {
        // TODO Not implemented for this example
    }
}

And our MainActivity uses those constants like so:

package tknilsson.com.example.feature

import android.content.Intent
import android.support.v7.app.AppCompatActivity
import android.os.Bundle

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)


        val intent = Intent(this, ProfileActivity::class.java)
        intent.putExtra(ProfileActivity.PARAMETER_KEY_PROFILE_ID, "thomasId4321")
        intent.putExtra(ProfileActivity.PARAMETER_KEY_HAS_SUBSCRIPTION, true)
        startActivity(intent)
    }
}

This is fine if ProfileActivity is just launched from this one place. But if we do this from a bunch of different places in our app, it will feel somewhat clunky. Why should any other Activities even know about the details of how ProfileActivity is launchedĀ ā€” why not let it handle that itself?

I like to encapsulate that inside the target Activity itself:

package tknilsson.com.example.feature

import android.app.Activity
import android.content.Intent
import android.support.v7.app.AppCompatActivity
import android.os.Bundle

class ProfileActivity : AppCompatActivity() {

    companion object {
        val PARAMETER_KEY_PROFILE_ID = "profileId"
        val PARAMETER_KEY_HAS_SUBSCRIPTION = "hasSubscription"

        fun launch(fromActivity: Activity, profileId: String, hasSubscription: Boolean) {
            val intent = Intent(fromActivity, ProfileActivity::class.java)
            intent.putExtra(ProfileActivity.PARAMETER_KEY_PROFILE_ID, profileId)
            intent.putExtra(ProfileActivity.PARAMETER_KEY_HAS_SUBSCRIPTION, hasSubscription)
            fromActivity.startActivity(intent)
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_profile)

        val profileId = intent.extras.getString(PARAMETER_KEY_PROFILE_ID)
        val hasSubscription = intent.extras.getBoolean(PARAMETER_KEY_HAS_SUBSCRIPTION)

        initProfileView(profileId, hasSubscription)
    }

    private fun initProfileView(profileId: String?, hasSubscription: Boolean) {
        // TODO Not implemented for this example
    }
}

This way, all the places around our codebase where ProfileActivity is launched, we go from this mess:

val intent = Intent(this, ProfileActivity::class.java) 
intent.putExtra("profileId", "thomasId4321") 
intent.putExtra("hasSubscription", true) 
startActivity(intent)

To this:

ProfileActivity.launch(this, "thomasId4321", true)

To me this feels more readable and has less potential for bugs, especially when done from a bunch of different places in the app codebase.