Single Activity с Navigation Component. Или как я мучался с графами. Boilerplate ч. 1

в 18:31, , рубрики: android, FlowFragment, kotlin, Navigation Component, Single Activity Architecture, разработка мобильных приложений, Разработка под android

Всем привет! Меня зовут Алишер, Android-разработчик уже как 1,5 года. За это время у меня появился шаблонный (Boilerplate) проект в котором у нас базовая архитектура приложения. А в этой статье я расскажу, и покажу как я ел Single Activity Architecture с Fragment'ами и Navigation Component.

Для общего понимания необходимо прочитать отличную статью про Single Activity, Лицензия на вождение болида, или почему приложения должны быть Single-Activity, и для дополнения части Navigation Component-дзюцу.

В реализации Single Activity основной вопрос, на что заменить Activity? Основываясь на вышеперечисленных статьях мы будем заменять Activity на FlowFragment'ы, а что это? Это Fragment который выполняет функцию Activity. В Navigation Component это у нас фрагмент со своим контейнером и графом. Чтобы не писать лишний код, напишем базовый класс:

abstract class BaseFlowFragment(
    @LayoutRes layoutId: Int,
    @IdRes private val navHostFragmentId: Int
) : Fragment(layoutId) {

    final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        val navHostFragment =
            childFragmentManager.findFragmentById(navHostFragmentId) as NavHostFragment
        val navController = navHostFragment.navController

        setupNavigation(navController)
    }

    protected open fun setupNavigation(navController: NavController) {
    }
}

Это абстрактный класс с инициализацией navController'a, нужно уточнить момент так как это будет вложенный фрагмент в основной контейнер Activity, нам нужно при инициализации navController'a использовать childFragmentManager.

Далее приступим как это все будет выглядеть в реальном проекте. Самый простой пример у нас есть флоу Авторизации / Регистрации и Главная страница с нижней навигацией.

Создадим SignFlowFragment который отвечает за Авторизацию / Регистрацию. И MainFlowFragment для Главной страницы с нижней навигацией.

class SignFlowFragment : BaseFlowFragment(
    R.layout.flow_fragment_sign, R.id.nav_host_fragment_sign
)

class MainFlowFragment : BaseFlowFragment(
    R.layout.flow_fragment_main, R.id.nav_host_fragment_main
) {
    
    private val binding by viewBinding(FlowFragmentMainBinding::bind)

    override fun setupNavigation(navController: NavController) {
        binding.bottomNavigation.setupWithNavController(navController)
    }
}
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".presentation.ui.fragments.sign.SignFlowFragment">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host_fragment_sign"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:defaultNavHost="true"
        app:navGraph="@navigation/sign_graph" />


</FrameLayout>



<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".presentation.ui.fragments.main.MainFlowFragment">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host_fragment_main"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        app:defaultNavHost="true"
        app:layout_constraintBottom_toTopOf="@id/bottom_navigation"
        app:layout_constraintTop_toTopOf="parent"
        app:navGraph="@navigation/main_graph" />

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/bottom_navigation"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:menu="@menu/menu_bottom_navigation" />


</androidx.constraintlayout.widget.ConstraintLayout>

Дальше создаем SignIn и SignUp fragment'ы. И выстраиваем навигацию внутри sign_graph. Кейс такой что нам нужно навигировать с SignIn в SignUp и MainFlowFragment. А как навигировать между FlowFragment'ами. Создадим перед этим kotlin file NavigationExtensions:

fun Fragment.activityNavController() = requireActivity().findNavController(R.id.nav_host_fragment)

fun NavController.navigateSafely(@IdRes actionId: Int) {
    currentDestination?.getAction(actionId)?.let { navigate(actionId) }
}

fun NavController.navigateSafely(directions: NavDirections) {
    currentDestination?.getAction(directions.actionId)?.let { navigate(directions) }
}

activityNavController это у нас navController MainActivity который поможет нам навигировать между FlowFragment'ами. Остальные два extension'a для безопасной навигации, так как при быстрой навигации (либо быстро нажать на одну кнопку с переходом, либо две разные кнопки с переходами) происходит краш IllegalArgumentException.

Далее как происходит навигация с SignInFragment

private fun clickSignIn() {
    binding.buttonSignIn.setOnClickListener {
        UserData.isAuthorized = true
        activityNavController().navigateSafely(R.id.action_global_mainFlowFragment)
    }
}

private fun clickSignUp() {
    binding.buttonSignUp.setOnClickListener {
        findNavController().navigateSafely(R.id.action_signInFragment_to_signUpFragment)
    }
}

Но вот вопрос как это все связать в MainActivity и какой фрагмент должен открытся первым, такой кейс мы решим с помощью динамического сеттинга startDestination'а. Перед этим нужно убрать app:startDestination в основном графе и app:navGraph с FragmentContainerView.

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".presentation.ui.activity.MainActivity">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:defaultNavHost="true" />


</FrameLayout>
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/nav_graph"
    tools:ignore="InvalidNavigation">

    <action
        android:id="@+id/action_global_signFlowFragment"
        app:destination="@id/signFlowFragment"
        app:popUpTo="@id/nav_graph" />

    <action
        android:id="@+id/action_global_mainFlowFragment"
        app:destination="@id/mainFlowFragment"
        app:popUpTo="@id/nav_graph" />

    <fragment
        android:id="@+id/mainFlowFragment"
        android:name="com.alish.navigationflowsample.presentation.ui.fragments.main.MainFlowFragment"
        android:label="flow_fragment_main"
        tools:layout="@layout/flow_fragment_main" />

    <fragment
        android:id="@+id/signFlowFragment"
        android:name="com.alish.navigationflowsample.presentation.ui.fragments.sign.SignFlowFragment"
        android:label="flow_fragment_sign"
        tools:layout="@layout/flow_fragment_sign" />


</navigation>

Как происходит инициализация navController'a в MainActivity

private fun setupNavigation() {
    val navHostFragment =
        supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
    navController = navHostFragment.navController

    val navGraph = navController.navInflater.inflate(R.navigation.nav_graph)
    when {
        UserData.isAuthorized -> {
            navGraph.setStartDestination(R.id.mainFlowFragment)
        }
        !UserData.isAuthorized -> {
            navGraph.setStartDestination(R.id.signFlowFragment)
        }
    }
    navController.graph = navGraph
}

Плюс этого подхода. Мы решаем проблему SharedViewModel'a с Single Activity. При использовании by activityViewModels, наш ViewModel становиться Singleton'ом так как в таком случае ViewModel уничтожается при уничтожении activity, а он у нас только один на все приложение. Решаем это с помощью navGraphViewModels или hiltNavGraphViewModels, которые привязываются к графу и уничтожаются вместе с ними.

Результат всего выглядит так:

P.S. И да, переезжаем на Compose и зачем все это :) Если есть какие-то моменты, открыт к конструктивной критике.

Этот проект
Boilerplate

Автор:
TheAlisher

Источник


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


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