삽질도사

[안드로이드] SAA(Single Activity Architecture) + Navigation 후기 본문

안드로이드

[안드로이드] SAA(Single Activity Architecture) + Navigation 후기

전성진블로그 2024. 5. 10. 11:30

안녕하세요. 최근 몇 개월동안 3가지정도의 앱을 개발하면서 SAA를 적극적으로 사용해보았습니다.

SAA는 한마디로 액티비티를 하나만 사용해서 앱을 만드는 건데, 이것도 하다보니 실력이 늘고 익숙해지더라구요.

이제는 SAA가 아니면 아키텍쳐가 좀 복잡하거나 가독성이 떨어진다고 느껴질 정도 였습니다.

 

처음에 제가 SAA를 사용하게 된 계기는 단순히

1. 화면 간에 정보 교환이 어렵다. (intent가 쓰기 번거롭다.)

2. 화면이 많아질수록 화면이동이 힘들다.

3. 액티비티가 많아지니 앱이 무거워진다.

대략 이정도였는데요. 아무래도 보통 이러한 이유 때문에 플래그먼트를 섞어서 많이 사용합니다. 

그러면 또 들었던 생각이..

 

1. 액티비티+플래그먼트 -> 화면안에 화면을 디테일하게 구성하는 경우는 거의 없음 (일단 터치하기 힘들고 보기 좋지 않음) -> 어디까지 플래그먼트이고 어디까지가 액티비티인가 :결론: 헷갈리게 뭐하러 이것저것 쓰는가..?

2. only 액티비티 :결론: 액티비티가 플래그먼트보다 무겁기 때문에 비효율적.

3. only 프래그먼트 -> 액티비티가 최소 1개는 필요함 -> SAA(Single Activity Architecure) :결론: 액티비티만 쓰는 것보단 효율적.

 

결론: SAA가 가장 효율적(?).

 

개인적으로 0.01초라도 빠르고 메모리를 덜 쓰기 위해 많은 시도를 하는 개발자들이 액티비티를 여러개 쓸 이유가 전혀 없다(?)라고 그 당시에는 생각했습니다만 결론적으로는 쓰다보니 플래그먼트도 생명주기나 화면이동에서 완벽한 환경을 제공해주는 것은 아니므로 액티비티가 쓰고 싶다는 생각이 간절하게 드는 경우도 있었습니다. 😅

 

그리고 제가 써본 결과 적어도 제 능력안에서는 Navigation을 사용함으로써 화면이동이나 생명주기등 유지보수에도 많은 시너지 효과를 얻을 수 있었습니다. 그리고 그걸로 바텀네비게이션도 쉽게 연결해서 여러 효과를 간편하게 나타낼 수 있으니 굳이 안쓸 이유가 없었습니다.

 

출처: https://velog.io/@iamjm29/Single-Activity-Architecture-SAA-%EC%A0%81%EC%9A%A9%EA%B8%B0

 

위 이미지처럼 앱에서 액티비티는 그저 하나의 프레임이 된다고 생각합니다. 

기본적으로 모든 화면에서 보여지는 바텀네비게이션과 프래그먼트를 나타낼 컨테이너를 포함하고 있습니다.

따라서 바텀네비게이션의 동작과 글로벌하게 일어나는 액션들은 액티비티에서 처리하고

그 외에 프래그먼트에서 처리할 수 있는 동작들은 각각의 프래그먼트가 처리합니다.

 

  • activity_main.xml
<?xml version="1.0" encoding="utf-8"?>

<layout 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">

    <data>
        <variable
            name="viewModel"
            type="kr.foorun.uni_eat.feature.main.MainViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/fragmentContainerView"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:name="androidx.navigation.fragment.NavHostFragment"
            app:defaultNavHost="true"
            app:navGraph="@navigation/main_nav"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toTopOf="@id/bottom_nav"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            tools:layout="@layout/fragment_splash" />

        <com.google.android.material.bottomnavigation.BottomNavigationView
            android:id="@+id/bottom_nav"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:background="@color/white"
            app:labelVisibilityMode="labeled"
            android:visibility="gone"
            app:bottomAnimVisible="@{viewModel.visibleBottom}"
            app:itemIconTint="@drawable/menu_selector"
            app:itemTextColor="@drawable/menu_selector"
            app:menu="@menu/menu_main"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"/>
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

 

    •  MainActivity.kt
package kr.foorun.uni_eat.feature.main

import androidx.activity.viewModels
import androidx.navigation.NavController
import androidx.navigation.NavDirections
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.setupWithNavController
import dagger.hilt.android.AndroidEntryPoint
import kr.foorun.presentation.MainNavDirections
import kr.foorun.presentation.R
import kr.foorun.presentation.databinding.ActivityMainBinding
import kr.foorun.uni_eat.base.view.base.context_view.BaseActivity

@AndroidEntryPoint
class MainActivity : BaseActivity<ActivityMainBinding, MainViewModel>({ActivityMainBinding.inflate(it)}){

    override val activityViewModel: MainViewModel by viewModels()
    private lateinit var navController: NavController

    override fun afterBinding() = binding {
        setUpBottomNavigationView()
    }

    override fun observeAndInitViewModel() = binding {
        viewModel = activityViewModel.apply {
        }
    }

    private fun setUpBottomNavigationView() = binding {
        val navHostFragment = supportFragmentManager.findFragmentById(R.id.fragmentContainerView) as NavHostFragment
        navController = navHostFragment.navController //바텀 네비게이션을 등록해줘야합니다.
        setDestinationListener() //특정 프래그먼트에서 바텀네비가 나타나고 사라질 수 있게 해줌.
        setUpBottomNav() //바텀네비에 있는 버튼을 누를 때 보여줄 프래그먼트 지정
    }

    /**
     * @setupWithNavController(navController) is to fetch view into frame when click bottom icon
     *
     * @setOnItemSelectedListener is to make sure action works properly,
     * if you use only setupWithNavController, view is not attached on frame when click bottom icon.
     */
    private fun setUpBottomNav() = binding {
        bottomNav.setupWithNavController(navController) //to fetch view into frame when click bottom icon

        bottomNav.setOnItemSelectedListener {//to make sure action works properly if you use
            when(it.itemId){
                R.id.home_nav -> true.apply { navigate(MainNavDirections.actionToHomeNav()) }
                R.id.map_nav -> true.apply { navigate(MainNavDirections.actionToMapNav()) }
                R.id.event_nav -> true.apply { navigate(MainNavDirections.actionToEventNav()) }
                R.id.article_nav -> true.apply { navigate(MainNavDirections.actionToArticleNav()) }
                R.id.my_nav -> true.apply { navigate(MainNavDirections.actionToMyNav()) }
                else -> false
            }
        }
    }

    private fun navigate(directions: NavDirections) = navController.navigate(directions)

    private fun setDestinationListener() = navController.addOnDestinationChangedListener { controller, destination, arg ->
        if(arg != null){
            if (arg.isEmpty) bottomVisible(true)
            else if(arg.getBoolean(getString(R.string.hide_bottom))) bottomVisible(false)
        } else bottomVisible(true)
    }

    fun bottomVisible(isVisible: Boolean) = activityViewModel.setBottomVisible(isVisible)
}

 

위 코드처럼 메인액티비티에서 모든 것을 처리할 수 있습니다.

그리고 네비게이션을 활용해서 화면간에 데이터를 쉽게 주고 받을 수 있으므로 그걸 활용해서 특정 화면에서 바텀네비를 없애고 싶을 경우에 해당 프래그먼트에 특정한 값(ex: boolean)을 지정해주고 액티비티에서 인지하여 바텀네비의 유무를 컨트롤할 수 있습니다.

 

또한 네비게이션을 통해서 프래그먼트 컨테이너를 여러개 만들 수 있으므로 특정한 function에 대해서 여러 프래그먼트를 묶어 플로우를 정할 수 있습니다. 

 

  • navigation (main.xml)
<?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/main_nav"
    app:startDestination="@id/splash_nav">

    <include app:graph="@navigation/article_nav" />
    <include app:graph="@navigation/event_nav" />
    <include app:graph="@navigation/my_nav" />
    <include app:graph="@navigation/map_nav" />
    <include app:graph="@navigation/login_nav" />
    <include app:graph="@navigation/home_nav" />

    <fragment
        tools:layout="@layout/fragment_article_detail"
        android:id="@+id/article_detail_fragment"
        android:name="kr.foorun.uni_eat.feature.article.detail.ArticleDetailFragment"
        android:label="ArticleDetailFragment" >
        <argument
            android:name="@string/hide_bottom"
            app:argType="boolean"
            android:defaultValue="true" />
    </fragment>

    <action
        app:enterAnim="@anim/from_right"
        app:exitAnim="@anim/to_left"
        app:popUpToInclusive="true"
        app:popUpTo="@id/splash_fragment"
        android:id="@+id/action_to_loginFragment"
        app:destination="@id/login_nav" />

    <action
        android:id="@+id/action_to_articleDetailFragment"
        app:destination="@id/article_detail_fragment" />

    <action
        android:id="@+id/action_to_my_nav"
        app:popUpTo="@id/home_fragment"
        app:popUpToInclusive="false"
        app:destination="@id/my_nav" />

    <action
        android:id="@+id/action_to_map_nav"
        app:popUpTo="@id/home_fragment"
        app:popUpToInclusive="false"
        app:destination="@id/map_nav" />

    <action
        android:id="@+id/action_to_event_nav"
        app:popUpTo="@id/home_fragment"
        app:popUpToInclusive="false"
        app:destination="@id/event_nav" />

    <action
        android:id="@+id/action_to_article_nav"
        app:popUpTo="@id/home_fragment"
        app:popUpToInclusive="false"
        app:destination="@id/article_nav" />

    <action
        app:popUpToInclusive="true"
        app:popUpTo="@id/login_fragment"
        android:id="@+id/action_to_home_nav"
        app:destination="@id/home_nav" />
    <include app:graph="@navigation/splash_nav" />
</navigation>

예시입니다.

 

setUpBottomNavigationView()에서 이후에 나올 navigation xml에 보시는 바와 같이 hideBottom 이라는 argument를 설정해서 바텀네비 유무를 조절할 수 있습니다. 그리고 include를 통해서 커스텀된 네비게이션 덩어리를 import해서 화면에 띄운다던지 하는 액션을 설정해줄 수 있습니다. (이런 식으로 글로벌하게 쓰이는 바텀시트도 저기에 저장해놓고 아무 때나 재활용해서 띄울 수 있습니다.)

 

  • 장점

1.  intent를 사용하지 않고 argument를 navigation에서 제공해주기 때문에 쉽게 화면간에 데이터 교환이 가능하다.

2. 메모리/시간 모든 면에서 효율적이다.

3. 비교적 플로우 변화가 손쉽다.

4. 프래그먼트를 적극적으로 활용하기 때문에 코드의 재활용이 잘된다.

5. 플로우나 코드의 가독성이 획기적으로 좋아진다.

6. mvi 나 mvvm 같은 방식 + 클린코드 + 테스트 를 적용하기에 편하다. 

7. 화면전환이나 쌓여있는 화면들을 정리/이동하기가 쉽다.

8. Activity를 공유하기 때문에 뭐든지 애매할 때는 Activity를 활용하면 문제가 없다. 

 

  • 단점

1.  러닝커브가 좀 있다. (처음에 많이 헤맴 그래서 인수인계도 쉽지가 않다.)

2. 프래그먼트 간에 생명주기에 대해 생각해야할 부분이 많고 그로인해 생각지 못한 에러가 발생할 때가 있다.

3. 반대로 기존 프래그먼트의 사용방식으로 활용이 불편하다. (액티비티의 역할을 하기 때문에 navigation 외에 내부에 코딩을 해줘야함)

4. 기존 프래그먼트 클래스에 파라미터를 넣고 편하게 데이터를 주고 받는 등의 클래식한 방법을 사용할 수 없다.

 

결론:  사실 아직까지도 컴포즈를 쓸게 아니라면 SAA를 쓰지 않을 이유를 찾진 못했습니다.

너무나도 효율적이고 컴포즈를 쓰지 않는다는 가정하에 지금까지는 가장 안정되고 쓰기 편한 아키텍쳐인것 같습니다.

매달, 매년 새로운 기술과 획기적인 방법이 나타나니 또 언제 새로운 게 나타나고 사라질지 모르지만 한번 써보면 그전으로 돌아가기 힘든

좋은 아키텍쳐라고 생각합니다. 😅

 

팀프로젝트라서 당장은 공개할 수 없지만 정리된 예시용 코드가 준비되는대로 깃허브 주소 올리겠습니다. 🙇‍♂️