ActivityLifecycleCallbacks — слепое пятно в публичном API

в 14:43, , рубрики: activitylifecyclecallbacks, android, android development, dependency injection, kotlin, kotlin android, Блог компании Яндекс.Деньги, Проектирование и рефакторинг, разработка мобильных приложений, Разработка под android

ActivityLifecycleCallbacks — слепое пятно в публичном API - 1

С детства я люблю читать инструкции. Я вырос, но меня до сих пор удивляет то, как взрослые люди безалаберно относятся к инструкциям: многие из них считают, что все знают, и при этом пользуются одной-двумя функциями, в то время как их намного больше! Кто из вас пользовался функцией поддержания температуры в микроволновке? А она есть почти в каждой.

Однажды я решил почитать документацию к различным классам Android framework. Пробежался по основным классам: View, Activity, Fragment, Application, — и меня очень заинтересовал метод Application.registerActivityLifecycleCallbacks() и интерфейс ActivityLifecycleCallbacks. Из примеров его использования в интернете не нашлось ничего лучше, чем логирование жизненного цикла Activity. Тогда я начал сам экспериментировать с ним, и теперь мы Яндекс.Деньгах активно используем его при решении целого спектра задач, связанных с воздействием на объекты Acltivity снаружи.

Что такое ActivityLifecycleCallbacks?

Посмотрите на этот интерфейс, вот как он выглядел, когда появился в API 14:

public interface ActivityLifecycleCallbacks {
    void onActivityCreated(Activity activity, Bundle savedInstanceState);
    void onActivityStarted(Activity activity);
    void onActivityResumed(Activity activity);
    void onActivityPaused(Activity activity);
    void onActivityStopped(Activity activity);
    void onActivitySaveInstanceState(Activity activity, Bundle outState);
    void onActivityDestroyed(Activity activity);
}

Начиная с API 29 в него добавили несколько новых методов

public interface ActivityLifecycleCallbacks {
    default void onActivityPreCreated(
        @NonNull Activity activity,
        @Nullable Bundle savedInstanceState) { }
    void onActivityCreated(
        @NonNull Activity activity,
        @Nullable Bundle savedInstanceState);
    default void onActivityPostCreated(
        @NonNull Activity activity,
        @Nullable Bundle savedInstanceState) { }
    default void onActivityPreStarted(@NonNull Activity activity) { }
    void onActivityStarted(@NonNull Activity activity);
    default void onActivityPostStarted(@NonNull Activity activity) { }
    default void onActivityPreResumed(@NonNull Activity activity) { }
    void onActivityResumed(@NonNull Activity activity);
    default void onActivityPostResumed(@NonNull Activity activity) { }
    default void onActivityPrePaused(@NonNull Activity activity) { }
    void onActivityPaused(@NonNull Activity activity);
    default void onActivityPostPaused(@NonNull Activity activity) { }
    default void onActivityPreStopped(@NonNull Activity activity) { }
    void onActivityStopped(@NonNull Activity activity);
    default void onActivityPostStopped(@NonNull Activity activity) { }
    default void onActivityPreSaveInstanceState(
        @NonNull Activity activity,
        @NonNull Bundle outState) { }
    void onActivitySaveInstanceState(
        @NonNull Activity activity,
        @NonNull Bundle outState);
    default void onActivityPostSaveInstanceState(
        @NonNull Activity activity,
        @NonNull Bundle outState) { }
    default void onActivityPreDestroyed(@NonNull Activity activity) { }
    void onActivityDestroyed(@NonNull Activity activity);
    default void onActivityPostDestroyed(@NonNull Activity activity) { }
}

Возможно, этому интерфейсу уделяют так мало внимания, потому что он появился только в Android 4.0 ICS. А зря, ведь он позволяет нативно делать очень интересную вещь: воздействовать на все объекты Activity снаружи. Но об этом позже, а сначала внимательнее посмотрим на методы.

Каждый метод отображает аналогичный метод жизненного цикла Activity и вызывается в тот момент, когда метод срабатывает на какой-либо Activity в приложении. То есть если приложение запускается с MainActivity, то первым мы получим вызов ActivityLifecycleCallback.onActivityCreated(MainActivity, null).

Отлично, но как это работает? Тут никакой магии: Activity сами сообщают о том, в каком они состоянии. Вот кусочек кода из Activity.onCreate():

    mFragments.restoreAllState(p, mLastNonConfigurationInstances != null
            ? mLastNonConfigurationInstances.fragments : null);
}
mFragments.dispatchCreate();
getApplication().dispatchActivityCreated(this, savedInstanceState);
if (mVoiceInteractor != null) {

Это выглядит так, как если бы мы сами сделали BaseActivity. Только коллеги из Android сделали это за нас, еще и обязали всех этим пользоваться. И это очень даже хорошо!

В API 29 эти методы работают почти так же, но их Pre- и Post-копии честно вызываются до и после конкретных методов. Вероятно, теперь этим управляет ActivityManager, но это только мои догадки, потому что я не углублялся в исходники достаточно, чтобы это выяснить.

Как заставить ActivityLifecycleCallbacks работать?

Как и все callback, сначала их надо зарегистрировать. Мы регистрируем все ActivityLifecycleCallbacks в Application.onCreate(), таким образом получаем информацию обо всех Activity и возможность ими управлять.

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        registerActivityLifecycleCallbacks(MyCallbacks())
    }
}

Небольшое отступление: начиная с API 29 ActivityLifecycleCallbacks можно зарегистрировать еще и изнутри Activity. Это будет локальный callback, который работает только для этого Activity.

Вот и все. Но это вы можете найти, просто введя название ActivityLifecycleCallbacks в строку поисковика. Там будет много примеров про логирование жизненного цикла Activity, но разве это интересно? У Activity много публичных методов (около 400), и все это можно использовать для того, чтобы делать много интересных и полезных вещей.

Что с этим можно сделать?

А что вы хотите? Хотите динамически менять тему во всех Activity в приложении? Пожалуйста: метод setTheme() — публичный, а значит, его можно вызывать из ActivityLifecycleCallback:

class ThemeCallback(
    @StyleRes val myTheme: Int
) : ActivityLifecycleCallbacks {
    override fun onActivityCreated(
        activity: Activity, 
        savedInstanceState: Bundle?
    ) {
        activity.setTheme(myTheme)
    }
}

Повторяйте этот трюк ТОЛЬКО дома
Какие-то Activity из подключенных библиотек могут использовать свои кастомные темы. Поэтому проверьте пакет или любой другой признак, по которому можно определить, что тему этой Activity можно безопасно менять. Например, проверяем пакет так (по-котлиновски =)):

class ThemeCallback(
    @StyleRes val myTheme: Int
) : ActivityLifecycleCallbacks {
    override fun onActivityCreated(
        activity: Activity,
        savedInstanceState: Bundle?
    ) {
        val myPackage = "my.cool.application"
        activity
            .takeIf { it.javaClass.name.startsWith(myPackage) }
            ?.setTheme(myTheme)
    }
}

Пример не работает? Возможно, вы забыли зарегистрировать ThemeCallback в Application или Application в AndroidManifest.

Хотите еще интересный пример? Можно показывать диалоги на любой Activity в приложении.

class DialogCallback(val dialogFragment: DialogFragment) : Application.ActivityLifecycleCallbacks {
    override fun onActivityCreated(
        activity: Activity,
        savedInstanceState: Bundle?
    ) {
        if (savedInstanceState == null) {
            val tag = dialogFragment.javaClass.name
            (activity as? AppCompatActivity)
                ?.supportFragmentManager
                ?.also { fragmentManager ->
                    if (fragmentManager.findFragmentByTag(tag) == null) {
                        dialogFragment.show(fragmentManager, tag)
                    }
                }
        }
    }
}

Повторяйте этот трюк ТОЛЬКО дома
Конечно же, не стоит показывать диалог на каждом экране — наши пользователи не будут нас любить за такое. Но иногда может быть полезно показать что-то такое на каких-то конкретных экранах.

А вот еще кейс: что, если нам надо запустить Activity Тут все просто: Activity.startActivity() — и погнали. Но что делать, если нам надо дождаться результата после вызова Activity.startActivityForResult()? У меня есть один рецепт:

class StartingActivityCallback : Application.ActivityLifecycleCallbacks {
    override fun onActivityCreated(
        activity: Activity,
        savedInstanceState: Bundle?
    ) {
        if (savedInstanceState == null) {
            (activity as? AppCompatActivity)
                ?.supportFragmentManager
                ?.also { fragmentManager ->
                    val startingFragment = findOrCreateFragment(fragmentManager)

                    startingFragment.listener = { resultCode, data ->
                        // handle response here
                    }

                    // start Activity inside StartingFragment
                }
        }
    }

    private fun findOrCreateFragment(
        fragmentManager: FragmentManager
    ): StartingFragment {
        val tag = StartingFragment::class.java.name
        return fragmentManager
            .findFragmentByTag(tag) as StartingFragment?
                ?: StartingFragment().apply {
                    fragmentManager
                        .beginTransaction()
                        .add(this, tag)
                        .commit()
                }
    }
}

В примере мы просто закидываем Fragment, который запускает Activity и получает результат, а потом делегирует его обработку нам. Будьте осторожны: тут мы проверяем, что наша Activity является AppCompatActivity, что может привести бесконечному циклу. Используйте другие условия.

Усложним примеры. До этого момента мы использовали только те методы, которые уже есть в Activity. Как насчет того, чтобы добавить свои? Допустим, мы хотим отправлять аналитику об открытии экрана. При этом у наших экранов свои имена. Как решить эту задачу? Очень просто. Создадим интерфейс Screen, который сможет отдавать имя экрана:

interface Screen {
    val screenName: String
}

Теперь имплементируем его в нужных Activity:

class NamedActivity : Activity(), Screen {
    override val screenName: String  = "First screen"
}

После этого натравим на такие Activity специальные ActivityLifecycleCallback’и:

class AnalyticsActivityCallback(
    val sendAnalytics: (String) -> Unit
) : Application.ActivityLifecycleCallbacks {
    override fun onActivityCreated(
        activity: Activity,
        savedInstanceState: Bundle?
    ) {
        if (savedInstanceState == null) {
            (activity as? Screen)?.screenName?.let(sendAnalytics)
        }
    }
}

Видите? Мы просто проверяем интерфейс и, если он реализован, отправляем аналитику.

Повторим для закрепления. Что делать, если надо прокидывать еще и какие-то параметры? Расширим интерфейс:

interface ScreenWithParameters : Screen {
    val parameters: Map<String, String>
}

Имплементируем:

class NamedActivity : Activity(), ScreenWithParameters {
    override val screenName: String = "First screen"
    override val parameters: Map<String, String> = mapOf("key" to "value")
}

Отправляем:

class AnalyticsActivityCallback(
    val sendAnalytics: (String, Map<String, String>?) -> Unit
) : Application.ActivityLifecycleCallbacks {
    override fun onActivityCreated(
        activity: Activity,
        savedInstanceState: Bundle?
    ) {
        if (savedInstanceState == null) {
            (activity as? Screen)?.screenName?.let { name ->
                sendAnalytics(
                    name,
                    (activity as? ScreenWithParameters)?.parameters
                )
            }
        }
    }
}

Но это все еще легко. Все это было только ради того, чтобы подвести вас к по-настоящему интересной теме: нативное внедрение зависимостей. Да, у нас Яндекс.Деньгах есть Dagger, Koin, Guice, Kodein и прочее. Но на небольших проектах они избыточны. Но у меня есть решение… Угадайте какое?

Допустим, у нас есть некоторый инструмент, вроде такого:

class CoolToolImpl {
    val extraInfo = "i am dependency"
}

Закроем его интерфейсом, как взрослые программисты:

interface CoolTool {
    val extraInfo: String
}

class CoolToolImpl : CoolTool {
    override val extraInfo = "i am dependency"
}

А теперь немного уличной магии от ActivityLifecycleCallbacks: мы создадим интерфейс для внедрения этой зависимости, реализуем его в нужных Activity, а с помощью ActivityLifecycleCallbacks найдем его и внедрим реализацию CoolToolImpl.

interface RequireCoolTool {
    var coolTool: CoolTool
}

class CoolToolActivity : Activity(), RequireCoolTool {
    override lateinit var coolTool: CoolTool
}

class InjectingLifecycleCallbacks : ActivityLifecycleCallbacks {
    override fun onActivityCreated(
        activity: Activity,
        savedInstanceState: Bundle?
    ) {
        (activity as? RequireCoolTool)?.coolTool = CoolToolImpl()
    }
}

Не забудьте зарегистрировать InjectingLifecycleCallbacks в вашем Application, запускайте — и все работает.

И не забудьте протестировать:

@RunWith(AndroidJUnit4::class)
class DIActivityTest {
    @Test
    fun `should access extraInfo when created`() {
        // prepare
        val mockTool: CoolTool = mock()
        val application = getApplicationContext<android.app.Application>()
        application.registerActivityLifecycleCallbacks(
            object : Application.ActivityLifecycleCallbacks {
                override fun onActivityCreated(
                    activity: Activity,
                    savedInstanceState: Bundle?
                ) {
                    (activity as? RequireCoolTool)?.coolTool = mockTool
                }
            })

        // invoke
        launch<DIActivity>(Intent(application, DIActivity::class.java))

        // assert
        verify(mockTool).extraInfo
    }
}

Но на больших проектах такой подход будет плохо масштабироваться, поэтому я не собираюсь отбирать ни у кого DI-фреймворки. Куда лучше объединить усилия и достигнуть синергии. Покажу на примере Dagger2. Если у вас в проекте есть какая-то базовая Activity, которая делает что-то вроде AndroidInjection.inject(this), то пора ее выкинуть. Вместо этого сделаем следующее: 

  1. по инструкции внедряем DispatchingAndroidInjector в Application;
  2. создаем ActivityLifecycleCallbacks, который вызывает DispatchingAndroidInjector.maybeInject() на каждой Activity;
  3. регистрируем ActivityLifecycleCallbacks в Application.
class MyApplication : Application() {
    @Inject lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>

    override fun onCreate() {
        super.onCreate()
        DaggerYourApplicationComponent.create().inject(this);
        registerActivityLifecycleCallbacks(
            InjectingLifecycleCallbacks(
                dispatchingAndroidInjector
            )
        )
    }
}

class InjectingLifecycleCallbacks(
    val dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
) : ActivityLifecycleCallbacks {
    override fun onActivityCreated(
        activity: Activity,
        savedInstanceState: Bundle?
    ) {
       dispatchingAndroidInjector.maybeInject(activity)
    }
}

И такого же эффекта можно добиться с другими DI-фреймворками. Попробуйте и напишите в комментариях, что получилось.

Подведем итоги

ActivityLifecycleCallbacks — это недооцененный, мощный инструмент. Попробуйте какой-нибудь из этих примеров, и пусть они помогут вам в ваших проектах так же, как помогают Яндекс.Деньгам делать наши приложения лучше.

Автор: Lynnfield

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js