Posts Android - Single Activity Approach
Post
Cancel

Android - Single Activity Approach

At the android level activities are really a component. They are at the same level as content providers, broadcast receivers and services. They are pretty much the UI facing part of the app.

There is a quote by Dianne Hackborn - “Once we have gotten in to this entry-point to your UI, we really don’t care how you organize the flow inside.”

This was pretty controversial. A lot of people did not agree with this at all.

If we think about the quote a bit more, maybe it can be interpreted as “The framework should not care about your applications architecture”. But, the developer, should certainly care about the architecture of your app.

The capability and behavior of an Activity is tied to API level. New things become possible on new versions of Android, that are not supported by activities simple as they are difficult or impossible to back-port.

So then why even have activities beyond an entry point to the app? Well, actually you don’t need to use them beyond the entry point at all.

Let’s take a look at an example:

1
2
3
4
ActivityCompat.startActivity(activity,
    Intent(activity, NavigationActivity::class.java),
    ActivityOptionsCompat.makeSceneTransitionAnimation(
        activity,heroElement,"hero"))

We have to use ActivityCompat because we want to have a shared element transition. What API level does this actually work on? It’s actually only going to do the shared element transition on devices with API 21+. Also, this is not the most readable code. Similarly, this makes the status and navigation bar flicker on some devices, which is not very reproducible unless you test on many, many devices.

Another example, say you have two activities and you want to share data between them. There is not much of a scope you can use in that case, the only scope available is the application scope.

Let’s define what a “destination” is. It is really just a subsection of the UI. For the majority, this will change the vast majority of our screen.

So, we want to have something that observes a view model and changes the destination based on observations. One implementation of this, is using fragments. Thus, due to having a smaller scope, the actual activity class is almost empty.

If we are moving from activities to destinations, we want to make the destination world as easy as possible, or else why would we use it. So, here comes in the navigation architecture component. What this allows us to do is to take an activity and simply transition it to fragments.

Additionally, for any destination in our fragment graph we can add a deep link in our navigation/main.xml. This also generates the intent filters for us by adding a navigation graph. This is in manifest merger.

All of this layering helps build nicer APIs, but it also makes it easier to test applications. If we are testing at activity level how do we test if start activity got the right intent? And then we get testing frameworks on top of testing frameworks to try to mock up all of these things. If we move to the single activity world, we don’t test at the destination level. Extract business logic out of the destination to test at isolation. For example, write tests against a view model and test it separately from the UI. This doesn’t mean that we don’t want to test anything in the UI however. We have espresso tests for a reason.

We can use this using fragment scenario which is part of fragment testing. It is built around testing fragments in isolation. It is super useful for things like espresso tests. It is built upon activity scenario.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Test
fun testProfileFragment() {
    val userUid = "test"
    val directions = MainFragmentDirections.showProfile(userUid)

    val scenario: FragmentScenario<ProfileFragment> =
        launchFragmentContainer(directions.arguments)

    scenario.onFragment{ fragment ->
        val args = ProfileFragmentArgs.fromBundle(fragment.arguments)
        assertThat(args.userUid).isEqualTo(userUid)
    }

    // or using espresso tests
    onView(withId(R.id.user_name))
        .check(matches(withText(userUid)))

    onView(withId(R.id.subscribe))
        .perform(click())
        .check(matches(withText("Subscribed")))
}

We can run any logic or espresso tests on our fragment as simple as that.

But, fragments do talk to other fragments, so we don’t always want to test in isolation. So, how can we test the navigation? Well, as we are using higher level components, we have a mockable layer. So now, we can just mock out the navigation controller and confirm we are navigating correctly.

1
2
3
4
5
6
7
8
class ProfileFragment(): Fragment() {
    val userUid by lazy { ProfileFragmentArgs.fromBundle(arguments).userUid }

    fun onViewSubscribers() {
        val directions = ProfileFragmentDirections.viewSubscribers(userUid)
        findNavController().navigate(directions)
    }
}

Let’s make a test for this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
fun testViewSubscribersNavigation() {
    val userUid = "test"
    val directions = MainFragmentDirections.showProfile(userUid)
    val scenario = launchFragmentInContainer<ProfileFragment>(directions.arguments)

    val navigationController = mock(NavController::class.java)
    scenario.onFragment{ fragment ->
        Navigation.setNavController(fragment.view!!, navigationController)
    }

    onView(withId(R.id.view_subscribers)).perform(click())
    val viewDirections = ProfileFragmentDirections.viewSubscribers(userUid)
    verify(navigationController).navigate(viewDirections)
}

But, so many things are not a service locator kind of pattern, we need to inject in dependencies. So there’s a class in Android P called AppComponentFactory which allows us to construct activities, services and broadcast receivers all via dependency injection. The same thing with fragments where now we can actually do constructor injection into fragments. We no longer need to have only a non-arg constructor fragment. This can replace casting activities to an interface in onAttach().

This post is licensed under CC BY 4.0 by the author.

Recent Update

    Contents