月: 2022年6月

[コードの見通し]可能な限り変数に置く

前回の話の続きです。読みやすいコードってどういうコードなのかって考えると「頭をできるだけつかわずにコードを読める」というのが読みやすいコードかなと思います。その一つとして変数は可能な限り定数宣言しておくとコードの読みやすさが変わってきます。Kotlinだとvar変数はあとから中身が変わるので、コードを読む上では「覚えていかないといけない」変数になるため頭を使うことになります。一方でval変数はあとで中身が変わらないのでコードを読む上では「一旦は覚えておかなくてもよい」変数になるので、頭をあまり使わないで済みます。
// ViewBinding    
private var _viewBinding: FragmentBinding? = null
private val viewBinding get() = _viewBinding!!

val変数で表現できる場合は可能な限りvalを使うようにしましょう。例えばダイヤログなどは引数を取らない場合は表示するか非表示にするかしか使われないので、下記のように変数にしてしまいましょう。

// https://github.com/afollestad/material-dialogsを利用
private val dialog: MaterialDialog by lazy {
     MaterialDialog(requireContext()).apply {
         cancelOnTouchOutside(false)
         title(R.string.title)
         message(R.string.message)
         positiveButton(android.R.string.ok) {
             startActivity(
                 Intent(
                     Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
                     Uri.parse("package:" + requireActivity().packageName)
                 )
             )
         }
         negativeButton(android.R.string.cancel) {
             dismiss()
        }
    }
}

// 表示非表示時はshow() or dismiss()
dialog.show()
dialog.dismiss()

// 変数にしないと毎回長いコードを読むことになる
MaterialDialog(requireContext()).apply {
    cancelOnTouchOutside(false)
    title(R.string.title)
    message(R.string.message)
    positiveButton(android.R.string.ok) {
        startActivity(
            Intent(
                Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
                Uri.parse("package:" + requireActivity().packageName)
            )
        )
    }
    negativeButton(android.R.string.cancel) {
        dismiss()
    }
}.show()
MaterialDialog(requireContext()).apply {
    cancelOnTouchOutside(false)
    title(R.string.title)
    message(R.string.message)
    positiveButton(android.R.string.ok) {
        startActivity(
            Intent(
                Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
                Uri.parse("package:" + requireActivity().packageName)
            )
        )
    }
    negativeButton(android.R.string.cancel) {
        dismiss()
    }
}.dismiss()

// 通知関連の変数も処理の途中で用意するのではなく、定数に置いておけばコードの見通しは良くなります
private val notificationManager by lazy { (requireContext().getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) }
private val notificationManagerCompat by lazy { NotificationManagerCompat.from(requireContext()) }
private val notificationCompatBuilder by lazy { NotificationCompat.Builder(requireContext(), getString(R.string.checker_notification_channel_id)) }

このように関数内で初期化する必要が無い変数は可能な限りval変数で定義することをおすすめします。


[コードの見通し]関数に分けるということ

プログラミングの勉強で「処理は関数にまとめる」ということが書いてあります。自分も仕事でコードを書くとコードレビューで「処理は関数にまとめてください」とか「コードの見通しが悪いです」とかレビューされます。コード見通し?ってなんのことか最初よくわからなかったです。

例として関数にまとめないコード例は下記になります。内容はとりあえず無視してください。

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        DaggerMainActivityComponent.factory()
                .create(this, this)
                .inject(this)
        super.onCreate(savedInstanceState)

        viewBinding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(viewBinding.root)

        /**
         * 
         * 通知チャネルを作成処理
         * 8行
         *
         */
        /**
         * 
         * ブロードキャストIntent宣言処理
         * 6行
         *
         */
        /**
         * 
         * 広告の初期化処理
         * 2行
         *
         */
        /**
         * 
         * Actionbarの初期化処理
         * 2行
         *
         */
        /**
         * 
         * NavigationDrawerの初期化処理
         * 10行
         *
         */
        /**
         * 
         * InAppUpdates確認処理
         * 7行
         *
         */
        /**
         * 
         * LiveDataを観測する処理
         * 6行
         *
         */
        /**
         * 
         * LiveDataを観測する処理
         * 6行
         *
         */
        /**
         * 
         * 初回起動時の処理
         * 2行
         *
         */
    }
}

こういうコードをそのまま書いている人いませんか?1つの関数に30行も40行も書かれると読む側の人はとても大変です。通常の場合、コードは上から実行されるので実装者もレビュワーもコードは基本的に上から見ていきます。例えばLiveDataを観測する処理にバグが入っているとわかってはいるが、修正をしようとすると上から順番にコードを読むため、40行ほどのコードを読まないといけません。人間の頭の中のメモリは限られているので、目的の場所に素早く到着し、余計な情報は入れないでバグを修正したいです。そのために処理を関数に分けます。

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        DaggerMainActivityComponent.factory()
                .create(this, this)
                .inject(this)
        super.onCreate(savedInstanceState)

        viewBinding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(viewBinding.root)

        createNotificationChannel()
        registerPackageAddedReceiver()
        setupAd()
        setupActionBar()
        setupNavigationDrawer()
        checkInAppUpdates()
        observeViewModel()
        loadIsFirstLaunch()
    }

    private fun createNotificationChannel() {}
    private fun registerPackageAddedReceiver() {}
    private fun setupAd() {}
    private fun setupActionBar() {}
    private fun setupNavigationDrawer() {}
    private fun checkInAppUpdates() {}
    private fun observeViewModel() {}
    private fun loadIsFirstLaunch() {}
}

このコードを上から読んでLiveDataの観測処理に辿り着くには15行ほどのコードを見て、observeViewModel()関数にコードジャンプすれば目的のコードが見れます。このように関数を分けることで目的のコードに辿り着く手間が格段に少なくなります。見るべきファイルが多ければ多いほど関数を分ける意味がボディーブローのようにジワジワ聞いてきます。パッと見てなんのコードが書かれているかわからないコードは見通しが悪いので関数にわけましょう。


画面遷移(Navigation)を処理するモジュールを作成し、ユニットテストを実行できるようにする

以前は画面遷移にはIntentを使ったりしていましたが、最近はNavigationとか出てきて画面遷移方法が変わってきました。今回は画面遷移用のモジュールを作成し、ユニットテストできるようにしてみます。例としてMainActivity内にデフォルトでRulesFragmentが表示されていて、HistoriesFragmentに遷移できて戻るボタンを1回押すとRulesFragmentに戻り、更に戻るボタンを1回押すとアプリが終了するとします。

対象

  • Navigationのユニットテストを書きたい方
  • 画面遷移用のモジュールを作成したい方

またNavigationの使い方は今回は説明しません(BackStackの積み方とかが今までと違うのでそのあたりは理解した上でテストコードを読むことをオススメします。

class MainActivity : AppCompatActivity()
    @Inject // Dagger or HiltでDIする場合
    lateinit var router: MainActivityRouter
    private lateinit var viewBinding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        viewBinding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(viewBinding.root)
   }
}
<!-- activity_main.xml レイアウトファイル -->
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    android:layout_height="match_parent"
    android:layout_width="match_parent">

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

</LinearLayout>
<!-- activity_main.xml ナビゲーションファイル -->
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/main_activity_navigation"
    app:startDestination="@id/rulesFragment">

    <fragment
        android:id="@+id/rulesFragment"
        android:name="com.example.ui.fragment.RulesFragment"
        android:label="fragment_rules">

        <action
            android:id="@+id/action_rules_to_histories"
            app:destination="@id/historiesFragment"
            app:popUpTo="@id/rulesFragment"/>

    </fragment>

    <fragment
        android:id="@+id/historiesFragment"
        android:name="com.example.ui.fragment.HistoriesFragment"
        android:label="fragment_histories">
    
        <action
            android:id="@+id/action_histories_to_rules"
            app:destination="@id/rulesFragment"/>

    </fragment>

</navigation>
class RulesFragment : Fragment() {
    companion object {
        const val argKey = "arg"
        operator fun invoke(arg: Boolean?): RulesFragment =
                RulesFragment().apply {
                    val bundle = Bundle().also { b ->
                        param?.let { p ->
                            b.apply {
                                putBoolean(argKey, p.arg)
                            }
                        }
                    }
                    this.arguments = bundle
                }
    }
    private val argument: Boolean? get() = arguments?.getBoolean(argKey)
}
class HistoriesFragment : Fragment() { }
interface MainActivityRouter {
    fun toRules(@IdRes container: Int, arg: Boolean?)
    fun toHistories(@IdRes container: Int)
}
internal class MainActivityRouterImpl(
        private val activity: AppCompatActivity
) : MainActivityRouter {
    override fun toRules(@IdRes container: Int, arg: Boolean?) {
        val navController = activity.findNavController(container)
        if (navController.currentDestination?.id == R.id.rulesFragment) return
        navController.navigate(
                when (navController.currentDestination?.id) {
                    R.id.historiesFragment -> R.id.action_histories_to_rules
                    else -> throw IllegalStateException("unknown navigation")
                },
                Bundle().apply { arg?.let {
                    putBoolean(RulesFragment.argKey, arg)
                } }
        )
    }
    override fun toHistories(@IdRes container: Int) {
        val navController = activity.findNavController(container)
        if (navController.currentDestination?.id == R.id.historiesFragment) return
        navController.navigate(
                when (navController.currentDestination?.id) {
                    R.id.rulesFragment -> R.id.action_rules_to_histories
                    else -> throw IllegalStateException("unknown navigation")
                }
        )
    }
}

https://developer.android.com/guide/navigation/navigation-testing?hl=ja

Navigationのテストコードを書く場合はTestNavHostControllerを使います。val navController = TestNavHostController(ApplicationProvider.getApplicationContext())で初期化でき、Fragment内でテストしたい場合はlaunchFragmentInContainer<EmptyFragment>(themeResId = R.style.Theme_MaterialComponents_Light).onFragment()で起動したFragmentのViewを指定

Navigation.setViewNavController(fragment.requireView(), navController)

Activity内でテストしたい場合は今回のようなMainActivityを用意してlaunchActivity<MainActivity>().onActivity()で起動したActivityのViewを指定

Navigation.setViewNavController(activity.requireViewById(R.id.fragment_container), navController)

このようにすることでユニットテストでTestNavHostControllerを利用できるようになります。

テストコードは下記のようになります。

@RunWith(AndroidJUnit4::class)
class MainActivityRouterImplSpec {
    companion object {
        private const val ARG = true
    }
    private val navController = TestNavHostController(ApplicationProvider.getApplicationContext()).apply {
        setGraph(R.navigation.activity_main)
    }
    private fun launchActivity(block: (MainActivity) -> Unit): ActivityScenario<MainActivity> =
            launchActivity<MainActivity>().onActivity { activity ->
                activity.router = MainActivityRouterImpl(activity)
                Navigation.setViewNavController(activity.requireViewById(R.id.fragment_container), navController)
                block.invoke(activity)
            }

    @Test
    fun rules_to_rules_with_null() {
        launchActivity { activity ->
            activity.router.toRules(R.id.fragment_container, null)
            activity.router.toRules(R.id.fragment_container, null)
                        // 現在の状態を確認
            assertThat(navController.currentDestination?.id).isEqualTo(R.id.rulesFragment)
            assertThat(navController.currentDestination?.arguments?.size).isEqualTo(0)
                        // 1つ前のbackStackを確認
            assertThat(navController.previousBackStackEntry?.destination?.id).isNull()
            assertThat(navController.previousBackStackEntry?.arguments).isNull()
                        // backStack全体を確認
            assertThat(navController.backStack.size).isEqualTo(2)
            assertThat(navController.backStack[0].destination.id).isEqualTo(R.id.main_activity_navigation)
            assertThat(navController.backStack[0].destination.arguments.size).isEqualTo(0)
            assertThat(navController.backStack[1].destination.id).isEqualTo(R.id.rulesFragment)
            assertThat(navController.backStack[1].destination.arguments.size).isEqualTo(0)
        }
    }

    @Test
    fun histories_to_rules_with_param() {
        launchActivity { activity ->
            activity.router.toHistories(R.id.fragment_container)
            activity.router.toRules(R.id.fragment_container, true)
            assertThat(navController.currentDestination?.id).isEqualTo(R.id.rulesFragment)
            assertThat(navController.currentDestination?.arguments?.size).isEqualTo(0)
            assertThat(navController.previousBackStackEntry?.destination?.id).isEqualTo(R.id.rulesFragment)
            assertThat(navController.previousBackStackEntry?.arguments).isNull()
            assertThat(navController.backStack.size).isEqualTo(3)
            assertThat(navController.backStack[0].destination.id).isEqualTo(R.id.main_activity_navigation)
            assertThat(navController.backStack[0].destination.arguments.size).isEqualTo(0)
            assertThat(navController.backStack[1].destination.id).isEqualTo(R.id.rulesFragment)
            assertThat(navController.backStack[1].destination.arguments.size).isEqualTo(0)
            assertThat(navController.backStack[2].destination.id).isEqualTo(R.id.rulesFragment)
            assertThat(navController.backStack[2].destination.arguments.size).isEqualTo(0)
            assertThat(navController.backStack[2].arguments?.getBoolean(RulesFragment.argKey)).isEqualTo(true)
        }
    }

    @Test
    fun rules_to_histories() {
        launchActivity { activity ->
            activity.router.toHistories(R.id.fragment_container)
            assertThat(navController.currentDestination?.id).isEqualTo(R.id.historiesFragment)
            assertThat(navController.currentDestination?.arguments?.size).isEqualTo(0)
            assertThat(navController.previousBackStackEntry?.destination?.id).isEqualTo(R.id.rulesFragment)
            assertThat(navController.previousBackStackEntry?.arguments).isNull()
            assertThat(navController.backStack.size).isEqualTo(3)
            assertThat(navController.backStack[0].destination.id).isEqualTo(R.id.main_activity_navigation)
            assertThat(navController.backStack[0].destination.arguments.size).isEqualTo(0)
            assertThat(navController.backStack[1].destination.id).isEqualTo(R.id.rulesFragment)
            assertThat(navController.backStack[1].destination.arguments.size).isEqualTo(0)
            assertThat(navController.backStack[2].destination.id).isEqualTo(R.id.historiesFragment)
            assertThat(navController.backStack[2].destination.arguments.size).isEqualTo(0)
    }
}