Skils/Android

[Android] - BaseActivity, BaseFragment .. BaseX에 대한 고찰

재한 2024. 4. 22. 00:32

Android 개발을 시작한 지 얼마 지나지 않았을 때 BaseActivity, BaseFragment를 생성하고,

Activity와 Fragment가 이를 상속받는 코드의 형태를 봤었습니다.

 

그때 당시는 더 복잡하고, 보는 사람으로 하여금 혼동을 주는 코드라고 생각했지만, BaseActivity와 Fragment를 상속받음으로써 해결되는 다양한 장점들이 있었습니다.

 

왜?

AppCompatActivity, Fragment가 아닌 BaseActivity, BaseFragment를 상속받는 것은 중복된 코드의 발생을 줄일 수 있습니다.

 

뭐 얼마나 코드의 중복이 발생하길래 복잡한 구조(BaseActivity와 BaseFragment)를 택하는걸까라고 생각하실 수 있습니다.

class TestActivity : AppCompatActivity {
    private lateinit var binding : ActivityTestBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_Test)
        binding.lifecycleOwner = this
    }
}
class TestFragment : Fragment {

    private var _binding: FragmentTestBinding? = null
    private val binding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?,
    ): View? {
        _binding =
            DataBindingUtil.inflate(layoutInflater, R.layout.fragment_test, container, false)
        binding.lifecycleOwner = viewLifecycleOwner
        return binding.root
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

 

간단한 예로, Activity와 Fragment에서 필연적으로 ViewBinding 혹은 DataBinding을 사용할 경우

binding 객체를 inflate하고, 해제하는 작업이 필요합니다.

사실상 코드 양으로 보면 그렇게 길지 않은 작업이라고 생각할 수 있습니다.

하지만 Android의 특성상 여러개의 뷰를 구현하고, 매번 Activity와 Fragment에서 binding객체를 inflate 하고 해제하는 작업은 생각보다 귀찮고, 부담스럽습니다.

 

이렇게 반복적인 코드의 발생을 줄이기 위해서 BaseActivity와 BaseFragment를 Activity와 Fragment가 상속받는 구조를 많이 사용하고 있습니다.

BaseActivity

abstract class BaseActivity<T : ViewDataBinding>(private val layoutResId: Int) :
    AppCompatActivity() {

    private lateinit var binding: T

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, layoutResId)
        binding.lifecycleOwner = this
        initView()
    }

    abstract fun initView()

    fun showMessage(message: String) {
        Snackbar.make(this.binding.root, message, Snackbar.LENGTH_LONG).show()
    }
}

다음과 같이 제네릭 타입을 이용해서 ViewDataBinding에 대응하는 binding 객체를 inflate하고, lifecycleOwner를 할당해 줍니다.

그 외에도 Activity에서 사용할 ToastMessage를 미리 구현해서 사용할 수도 있습니다.

class MainActivity : BaseActivity<ActivityMainBinding>(R.layout.activity_main) {
    override fun initView() {
        //UI 관련 초기화 작업
    }
}

이렇게 액티비티는 구현한 BaseActivity를 상속받으면서, binding 객체에 대한 코드를 줄일 수 있습니다.

initView를 통해서 onCreate에서 해야 할 작업들을 명시해 주면 코드가 굉장히 깔끔해짐을 알 수 있습니다.

BaseFragment

abstract class BaseFragment<T : ViewDataBinding>(private val layoutResId: Int) : Fragment() {
    private var _binding: T? = null
    private val binding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?,
    ): View? {
        _binding = DataBindingUtil.inflate(inflater, layoutResId, container, false)
        binding.lifecycleOwner = viewLifecycleOwner
        return binding.root
    }

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

    abstract fun initView()

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }

    fun setupBackStack() {
        findNavController().popBackStack()
    }

    fun showMessage(message: String) {
        Snackbar.make(this.requireView(), message, Snackbar.LENGTH_LONG).show()
    }
}

 

Fragment에서도 binding 객체를 할당하고, 해제하는 작업을 정의하고, 그 외에 뒤로 가기, 토스트메시지등을 구현합니다.

 

이를 상속받는 Fragment의 코드는 다음과 같습니다.

class MainFragment : BaseFragment<FragmentMainBinding>(R.layout.fragment_main) {

    override fun initView() {
		//UI 관련 초기화 작업
    }
}

굉장히 Fragment 코드 양이 줄어듬을 알 수 있습니다.

 

저번 프로젝트의 구현에서는 BaseViewModel 구현을 통해서 Network의 Connection 상태를 감지하는 코드를 작성한 적도 있습니다.

BaseViewModel

@HiltViewModel
open class BaseViewModel
@Inject
constructor(
    private val logoutEventRepository: LogoutEventRepository,
    private val networkManager: NetworkManager,
) : ViewModel() {

    private val _isConnected = MutableStateFlow(false)
    val isConnected: StateFlow<Boolean> = _isConnected

    init {
        networkManager.registerNetworkCallback()
        observerNetworkConnection()
    }

    private fun observerNetworkConnection() {
        viewModelScope.launch {
            networkManager.isConnected.collectLatest { isConnected ->
                _isConnected.update { isConnected }
            }
        }
    }

    override fun onCleared() {
        super.onCleared()
        networkManager.unRegisterNetworkCallback()
    }
}

 

정리하자면 BaseActivity, BaseFragment, BaseViewModel를 통해서 다음과 같은 이점을 얻을 수 있습니다.

  1. 액티비티, 프래그먼트, 뷰모델에서 공통적으로 사용하는 코드의 양을 줄일 수 있습니다.
  2. 특정 생명주기에 대응해서 공통적으로 사용하는 코드의 양을 줄일 수 있습니다.

 

하지만 이렇게 BaseActivity, BaseFragment, BaseViewModel의 사용은 독이 될 때가 있습니다.

 

특히 이런 문제점은 상속 관계를 구축할 때 많이 겪는 문제점입니다.

 

1. 액티비티, 프래그먼트, 뷰모델에서 사용되는 함수들을 BaseX에 추가할 때마다, 크기가 방대해진다는 점입니다.

점점 BaseX의 기능이 많아짐에 따라, 당연하게도 변화에 취약한 환경을 가질 수밖에 없습니다.

(이를 위해 우리는 클래스를 분리하는 작업을 선호합니다)

2. BaseX의 기능을 모두 사용하지 않는 특정 컴포넌트가 생길 수 있습니다.

하지만 모든 액비비티, 프래그먼트, 뷰모델은 각자의 BaseX를 상속받기 때문에 어떻게 보면 매끄럽지 못한 상속관계라고 할 수 있습니다.

3. BaseX를 구현한 사람 외에는 알기 어렵습니다.

실제 경험담으로, BaseActivity를 사용하는 과정에서 logIn, logOut을 위한 토큰의 검사를 하는 코드가 있었고, 해당 코드를 위해 다른 팀원께서 BaseActivity의 코드를 추가했었습니다.

저는 다른 액티비티의 구현을 위해서 BaseActivity를 상속했고, 추가된 오버라이딩 함수에 의해 뇌정지가 왔었고, 물어봤었습니다..ㅎㅎ

이렇게 BaseX의 코드가 변경됨에 따라 당사자가 아니면 흐름을 알기 매우 어렵다는 점입니다.

 

정말 편리하고, 코드의 양을 줄이는 BaseX의 사용이지만, 상속관계를 잘못 이용함으로써 꽤 많은 단점이 생길 수 있습니다.

 

단점을 해결하기 위한 대안은 특정 액티비티나 프래그먼트에서 사용되는 함수들은 확장함수를 통해서 해결할 수 도 있고요.

상속의 장점이자, 단점이라고 생각하고 BaseX를 사용할 때, 최대한 범용적인 함수의 사용 목적으로 사용하고, 최대한 BaseX의 크기를 줄이는 것이  올바른 BaseX의 사용이 아닐까 생각합니다.