Android JetpackのNavigationを試して導入手順をまとめた

Google IO 2018で発表されたJetpackの新しいコンポーネントのNavigationをアプリに導入してみた。

developer.android.com

TL;DR

  • ToolbarBottom NavigationonClickイベントに関わる画面遷移をNavigationに置き換えてみた。
  • 試した結果、コード短縮をゲットできるのでNavigation導入していきたい。
  • Backボタン、Upボタンの挙動を制御してくれるNavigation氏、助かる。
  • Bottom Navigationにアニメーションが入ってしまうの厄介(回避策をまとめた)。
  • safe-argsの威力がすごかった。これ使うだけでも良さそう。
  • onClickイベントだけNavigationに置き換え、とかできるのでプロジェクトに合わせて導入場面をチョイスできそう。

モチベーション

このエントリを通してNavigationを実践投入することがモチベーション。ToolbarBottom NavigationonClickイベントに関わる画面遷移をNavigationでどのように実現するのかをコードと共にまとめていく。

gradle settingにnavigationを追加する

build.gradleに依存を追加していく。

    implementation "android.arch.navigation:navigation-fragment:1.0.0-alpha01"
    implementation "android.arch.navigation:navigation-ui:1.0.0-alpha01"
    implementation "android.arch.navigation:navigation-fragment-ktx:1.0.0-alpha01"
    implementation "android.arch.navigation:navigation-ui-ktx:1.0.0-alpha01"

これでNavigationを使える準備はOK。

Navigation Editorを使い画面遷移を定義していく

※ Navigation Editorを使うにはAndroid Studio 3.2が必要

このエントリの画面遷移は単純でHome画面、天気予報一覧画面と詳細画面の3つとToolbarのメニューから選択ができる設定画面がある。
resにnavigationフォルダを追加すると新規作成メニューから Navigation Resource Fileが選択できる。

f:id:n_soushi:20180604093213p:plain

Navigation EditorをつかってFragment、Activityの遷移を定義した。Fragmentの遷移は天気予報一覧(forecastsFragment)から詳細画面(forecastFragment)に矢印が結ばれていることで表現されている。homeFragmentやsettingsActivityはToolbarやBottomNavigationからの遷移があるためEditorで追加をした。
フラグメント間の遷移のtransitionはEditorから定義できる。今回のエントリでは詳細に使っていないので内容については割愛。

homeFragmentは開始位置として定義しているのでhomeアイコンが表示されている。

Editorからも遷移を定義できるがxmlからも修正ができる。xmlは次のように定義されている。xmlファイルは nav_graph.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:animateLayoutChanges="false"
          app:startDestination="@id/homeFragment"
    >

    <fragment
        android:id="@+id/homeFragment"
        android:name="me.soushin.sunshine.ui.home.HomeFragment"
        android:label="HomeFragment"
        />

    <fragment
        android:id="@+id/forecastsFragment"
        android:name="me.soushin.sunshine.ui.forecast.ForecastsFragment"
        android:label="ForecastsFragment"
        >
        <action
            android:id="@+id/toForecast"
            app:destination="@id/forecastFragment"
            />
    </fragment>

    <fragment
        android:id="@+id/forecastFragment"
        android:name="me.soushin.sunshine.ui.forecast.ForecastFragment"
        android:label="ForecastFragment"
        >
        <argument
            android:name="forecast"
            app:type="string"
            />
    </fragment>

    <activity
        android:id="@+id/settingsActivity"
        android:name="me.soushin.sunshine.ui.settings.SettingsActivity"
        android:label="activity_settings"
        tools:layout="@layout/activity_settings"
        />
</navigation>

MainActivityのfragmentエリアを修正する

Navigation導入前にfragmentの表示・切り替えを定義していたlayoutを次のようにfragmentタグを使い変更する。

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
    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"
    >

    <fragment
        android:id="@+id/navHostFragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_marginTop="48dp"
        android:layout_marginBottom="56dp"
        app:defaultNavHost="true"
        app:navGraph="@navigation/nav_graph"
        />
</android.support.design.widget.CoordinatorLayout>

android:nameを androidx.navigation.fragment.NavHostFragmentとすることでNavigation機能のターゲットとなる。app:defaultNavHostをtrueに設定するとBackボタンやUpボタンが連携される。app:navGraphはNavigation Editorで定義した nav_graphを指定している。


ここまででNavigationをコードから操作できる準備が整った。ここから各ナビゲーションエリアの遷移をコードで実現していく。

ToolbarとNavigaitonを連携させる

ToolbarとNavigaitonを連携させるには onOptionsItemSelectedonSupportNavigateUpを次のようにオーバーライドする。

class MainActivity : AbstractActivity() {

    val navController: NavController by lazy { findNavController(R.id.navHostFragment) }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        return NavigationUI.onNavDestinationSelected(item, navController) || super.onOptionsItemSelected(item)
    }

    override fun onSupportNavigateUp() = navController.navigateUp()
}

NavigationnUIにActionBarとNavcontrollerを渡すために setupActionBarWithNavControllerをonCreate内でコールする。

class MainActivity : AbstractActivity() {

    val navController: NavController by lazy { findNavController(R.id.navHostFragment) }

    override fun onCreate(savedInstanceState: Bundle?) {

        findViewById<Toolbar>(R.id.toolbar).also {
            setSupportActionBar(it)
            setupActionBarWithNavController(navController)
        }
    }
}

Navigationと連携させるための注意点

注意点としてNavigationが参照するメニューのIDとToolbarのmenu.xmlで定義するIDを一致させる必要がある。

# menu_main.xml

<menu 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"
    tools:context="me.soushin.sunshine.app.MainActivity">
    <item
        android:id="@+id/settingsActivity"
        android:orderInCategory="100"
        android:title="@string/action_settings"
        app:showAsAction="never" />
</menu>

---

# nav_graph.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:animateLayoutChanges="false"
          app:startDestination="@id/homeFragment"
    >

    <activity
        android:id="@+id/settingsActivity"
        android:name="me.soushin.sunshine.ui.settings.SettingsActivity"
        android:label="activity_settings"
        tools:layout="@layout/activity_settings"
        />
</navigation>

settingsActivityでidを一致させている。これでtoolbarの遷移がNavigationに切り替わった。

BottomNavigationとNavigaitonを連携させる

BottomNavigationもToolbarと同様にmenu xmlとnav_graph xmlのIDを一致させる必要がある。詳しくは後述するgithubのコードを参照して頂きたい。

Navigationと連携したコードはToolbarと同様に簡略化できる。次のようなコードになる。

class MainActivity : AbstractActivity() {

    val navController: NavController by lazy { findNavController(R.id.navHostFragment) }

    override fun onCreate(savedInstanceState: Bundle?) {

        findViewById<BottomNavigationView>(R.id.bottomNavigation)?.apply {
            setupWithNavController(navController)
        }
    }
}

ボトムナビゲーションの切り替え(タブ切り替え)にアニメーションが入ってしまう

navigation-ui-ktxで用意されている setupWithNavControllerを使うとボトムナビゲーションで選択したFragmentに切り替わるときにアニメーションが入ってしまう。Fragment間の遷移ではnavigation editorを使いtransitionを定義できるがボトムナビゲーションの切り替えのアニメーションは決め打ちでアニメーションが定義されてしまっている。

アニメーションが定義されているNavigationUIクラスのコードは次のようになっていた。

public class NavigationUI {

    private static boolean onNavDestinationSelected(@NonNull MenuItem item,
            @NonNull NavController navController, boolean popUp) {
        NavOptions.Builder builder = new NavOptions.Builder()
                .setLaunchSingleTop(true)
                .setEnterAnim(R.anim.nav_default_enter_anim)
                .setExitAnim(R.anim.nav_default_exit_anim)
                .setPopEnterAnim(R.anim.nav_default_pop_enter_anim)
                .setPopExitAnim(R.anim.nav_default_pop_exit_anim);

        // 
    }
}

NavigationUIを継承したり、このアニメーションを潰すスマートなやり方が見つからなかったので setupWithNavControllerを独自で実装した。独自というよりはアニメーションを呼び出している部分をカットしただけのBottomNNavigationViewの拡張関数である。

fun BottomNavigationView.setupWithNavController(navController: NavController) {
    this.setOnNavigationItemSelectedListener { item ->
        try {
            navController.navigate(item.getItemId(), null, NavOptions.Builder().build())
            true
        } catch (e: IllegalArgumentException) {
            false
        }
    }

    navController.addOnNavigatedListener { controller, destination ->
        val destinationId = destination.id
        val menu = this.getMenu()
        var h = 0
        val size = menu.size()
        while (h < size) {
            val item = menu.getItem(h)
            if (item.getItemId() == destinationId) {
                item.setChecked(true)
            }
            h++
        }
    }
}

Fragment間の遷移にNavigaitonを連携させる

val onClick: (Forecast) -> Unit = { forecast: Forecast ->
    Bundle().apply {
        putParcelable(ForecastFragment.KEY_FORECAST, forecast)
    }.let {
        view.findNavController().navigate(R.id.toForecast, it) ★
    }
}

NavController#navigateを呼び出せばNavigationと連携できる。第一引数のresIdにはnavigation editorで定義したactionのidを指定する。第二引数にはFragmentへ渡すargumentsを指定する。

このコードではresIdに誤りがある場合、予期しないFragment遷移となってしまう。またFragmentに渡すargumentsが本当に期待するものかは定かではない。ここでsafe-argsを使う出番である。

safe-argsを有効にする

Rootのbuild.gradleのdependencies/class_pathにsafe-argsを追加する

classpath "android.arch.navigation:navigation-safe-args-gradle-plugin:$navVersion"

モジュールのbuild.gradleにpluginを追加する

apply plugin: 'androidx.navigation.safeargs'

これでsafe-argsが有効になったのでプロジェクトをリビルドする。
ビルドが成功すると{from-Fragment}Directionsクラスと{to-Fragment}Argsクラスが生成される。生成されたクラスをナビゲーション遷移に使っていく。

{from-Fragment}Directionsクラスと{to-Fragment}Argsクラスを使う

遷移元のコードは次のようになる。

val onClick: (Forecast) -> Unit = { forecast: Forecast ->
    view.findNavController().navigate(ForecastsFragmentDirections.toForecast(gson.toJson(forecast)))
}

ForecastsFragmentDirectionsクラスを使い、safe-argsで生成されたtoForecastメソッドをコールしている。また引数にはnavigation editorで定義したargumentsを渡す。toForecastメソッドはnav_graph.xmlで定義したaction idが参照されている。

# nav_graph.xml
<navigation //
   >

    <fragment
        android:id="@+id/forecastsFragment"
        android:name="me.soushin.sunshine.ui.forecast.ForecastsFragment"
        android:label="ForecastsFragment"
        >
        <action
            android:id="@+id/toForecast"
            app:destination="@id/forecastFragment"
            />
    </fragment>

    <fragment
        android:id="@+id/forecastFragment"
        android:name="me.soushin.sunshine.ui.forecast.ForecastFragment"
        android:label="ForecastFragment"
        >
        <argument
            android:name="forecast"
            app:type="string"
            />
    </fragment>
</navigation>

遷移先は次のようなコードになる。

class ForecastFragment : AutoDisposeFragmentKotlin() {

    private val forecast: Forecast by lazy {
        ForecastFragmentArgs.fromBundle(arguments).let {
            gson.fromJson(it.forecast, Forecast::class.java)
        }
    }
}

ForecastFragmentArgsクラスを使い遷移元から渡されたargumentsを参照できる。

safe-argsはNavigation定義から遷移元、遷移先に必要なクラスとメソッドを用意してくれる。これによりNavigation定義どおりの実装が可能になるしコード品質も格段に向上できる。

まとめ

  • TL;DRにまとめたが、必要なところだけ徐々にNavigationに移行するのが良さそう。
  • Fragment間を遷移するだけでNavigationがよしなにback stackを操作しているのでBackボタン、Upボタンの挙動に気を使う必要が無くなりそう。
  • 既存プロジェクトでback stackを操作してFragment遷移履歴を残している場合は導入は慎重にいきたい。
  • 新規プロジェクトであれば迷わずNavigationを導入すれば良いと思う。
  • transitionやdeeplinkなどは試せていないので更にインプットしていきたい。

コード

これまでの紹介では断片的なコードとなっているためNavigationを有効にしたプルリクエストを残しています。参照いただき参考になれば幸いです。

Feature/add navigation by soushin · Pull Request #11 · soushin/sunshine-app · GitHub

Android Support LibraryのChipを触ってみた

Android Support LibraryのChipの使い方をまとめていく。ChipはMaterial DesignにあるがSupport Libraryに組み込まれたのは28.0.0.Alpha 1からである。

material.io

developer.android.com

よってgradle settingを次のように更新する。

ext {
    compileSdkVersion = 'android-P'
    appCompatVersion = '28.0.0-alpha1'
}

このエントリでは com.android.support:design:$appCompatVersionを使っているがAndroid Xの登場によりnamespaceが代わりversioningも更新されている。詳しくは次のページを参照してほしい。

Hello World, AndroidXのページにMigration from 28.0.0-alpha1にあるとおりandroid.supportからandroidx-packagedAndroidのマイグレーション機能がStudio 3.2から提供されている。

ChipのCustom Viewを作っていく

gradleを更新すると android.support.design.chip.Chipが使えるようになる。Chipのアイコンをコードから変更したいのでCustom Viewを実装していく。

<?xml version="1.0" encoding="utf-8"?>
<merge
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <android.support.design.chip.Chip
        android:id="@+id/chip"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        style="@style/Base.Widget.MaterialComponents.Chip"
        app:chipBackgroundColor="@color/color_primary_white"
        app:chipStrokeColor="@color/color_gray"
        app:chipStrokeWidth="1dp"
        app:checkedIconEnabled="false"
        app:iconStartPadding="4dp"
        app:closeIconEnabled="false"
        />
</merge>

layoutは上記のようにした。chipにはいくつかのstyleが用意されているので用途に合わせて参照すると良い。このlayoutではBase Styleをもとに必要な要素を調整した。

  • chipの背景色、外線色と太さ
  • iconのpadding(paddingはchip内の要素で細かく定義できる)
  • chipをcheckしたときの振る舞い(checkedIconEnabled="false")
  • closeボタンを表示しない
class ForecastChipView(context: Context, attrs: AttributeSet?,
                       defStyleAttr: Int) : RelativeLayout(context, attrs, defStyleAttr) {

    val chip: Chip by bindView(R.id.chip)

    init {
        LayoutInflater.from(context).inflate(R.layout.forecast_chip_view, this, true)
    }

    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)

    constructor(context: Context) : this(context, null, 0)

    fun bindForecast(forecast: Forecast) {
        forecast.let {
            chip.chipText = DateUtils.formatDateTime(context, forecast.dt * 1000L, DateUtils.FORMAT_NO_YEAR)
            chip.setChipIconResource(it.iconDrawableRes())
        }
    }
}

CustomViewクラスのForecastChipViewが上記のコードである。bindForecastでchipのテキストとchipIconをセットしている。コードからでもchipアイコンをダイナミックに変更できる。

ForecastChipViewクラスのchipは次のようになった。

f:id:n_soushi:20180530094907p:plain

ChipGroupもあるよ

Support LibraryにはChipクラスだけではなくchip達を格納するChipGroupクラスが提供されている。ChipGroupはFlexboxLayoutクラスを継承しているViewGroupである。 ChipGroupにchipをaddViewしているコードは次のようにした。

class ForecastChipGroupBinder<V : ViewType>(context: Context, viewType: V,
                                            private val forecasts: List<Forecast>) : RecycleBinder<V>(context, viewType) {

    override fun layoutResId() = R.layout.forecast_chip_group_binder

    override fun onCreateViewHolder(view: View) = ViewHolder(view)

    override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
        viewHolder as ViewHolder

        forecasts.forEach {
            viewHolder.chipGroup.addView(ForecastChipView(context).apply {
                bindForecast(it)
            })
        }
    }

    open class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val chipGroup: ChipGroup by bindView(R.id.chipGroup)
    }
}

コード

これまでのコードはgithubに置いてあるので参照していただきたい。参考になると嬉しいです。

Added sample chip view by soushin · Pull Request #9 · soushin/sunshine-app · GitHub

SwipeRefreshLayoutとRecycleViewをListViewに導入してみた

ブログエントリで試しているアプリにSwipeRefreshLayoutとRecycleViewをListViewに導入してみたのでまとめていく。

SwipeRefreshLayout

SwipeRefreshLayoutはViewGroupの1つで引っ張って更新する機能を導入することができる。

まずはレイアウトからまとめていく。

    <android.support.v4.widget.SwipeRefreshLayout
        android:id="@+id/swipeRefreshLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">

        // 更新する要素

        </LinearLayout>
    </android.support.v4.widget.SwipeRefreshLayout>
</android.support.constraint.ConstraintLayout>

SwipeRefreshLayoutの子要素は必ず1つになるようにレイアウトを組む必要がある。


次にコードをまとめていく。

swipeRefreshLayout.setOnRefreshListener { request() }

setOnRefreshListenerで更新処理をリスナーに登録する。

更新処理が完了したところでisRefreshingをfalseにすることでインジケータを停止することができる。

swipeRefreshLayout.isRefreshing = false

RecycleView

次にRecycleViewをまとめていく。RecycleViewは大量なリスト表示が必要や頻繁にデータが変わるような場面で限られたViewを効率的に維持してくれる。

RecyclerView.Adapterを実装する

1行のデータをViewとして生成するAdapterを実装する。FragmentからはRecyclerAdapterのreplaceAllを呼び出しRecyclerViewを更新する。

BaseRecyclerAdapterはRecyclerView.Adapterを継承してonCreateViewHolder, onBindViewHolder, getItemCountを実装している。onCreateViewHolder, onBindViewHolderはそれぞれViewHolderが生成に合わせてviewTypeやpositionに応じたオブジェクトをリストから取得して返している。

class RecyclerAdapter : BaseRecyclerAdapter<RecyclerView.ViewHolder>() {

    fun <B : Binder<RecyclerView.ViewHolder>> replaceAll(objects: List<B>) {
        clear()
        objects.withIndex().forEach {
            insert(it.index, it.value)
        }
        notifyDataSetChanged()
    }
}

open class BaseRecyclerAdapter<VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {

    private val mObjects: MutableList<Binder<VH>> = mutableListOf()

    override fun onCreateViewHolder(parent: ViewGroup,
                                    viewType: Int): VH = getItemByViewType(viewType).onCreateViewHolder(parent)

    override fun onBindViewHolder(holder: VH, position: Int) = getItem(position).onBindViewHolder(holder, position)

    override fun getItemCount(): Int = mObjects.size


    fun getItem(position: Int): Binder<VH> =
            mObjects.withIndex().filter { it.index == position }
                    .takeIf { it.isNotEmpty() }?.let { it[0].value }
                    ?: throw IllegalArgumentException("invalid position=${position}")

    private fun getItemByViewType(viewType: Int): Binder<VH> =
            mObjects.filter { it.getViewType() == viewType }
                    .takeIf { it.isNotEmpty() }?.let { it[0] }
                    ?: throw IllegalArgumentException("invalid viewType=${viewType}")

    fun insert(index: Int, obj: Binder<VH>) {
        mObjects.add(index, obj)
    }

    fun clear() {
        mObjects.clear()
    }
}

interface Binder<VH> {
    fun onCreateViewHolder(parent: ViewGroup): VH
    fun onBindViewHolder(viewHolder: VH, position: Int)
    fun getViewType(): Int
}

RecyclerView.ViewHolderを保持するBinderクラスを実装する

RecyclerView.ViewHolderは1行分のView参照を保持するクラス。このRecyclerView.ViewHolderを自作のBinderクラスで包みAdapterに応じたメソッドを提供する。

interface Binder<VH> {
    fun onCreateViewHolder(parent: ViewGroup): VH
    fun onBindViewHolder(viewHolder: VH, position: Int)
    fun getViewType(): Int
}

interface ViewType {
    fun viewType(): Int
}

abstract class RecycleBinder(private val context: Context, private val viewType: ViewType) : Binder<RecyclerView.ViewHolder> {

    @LayoutRes
    abstract fun layoutResId(): Int

    abstract fun onCreateViewHolder(view: View): RecyclerView.ViewHolder

    override fun onCreateViewHolder(parent: ViewGroup): RecyclerView.ViewHolder {
        return onCreateViewHolder(LayoutInflater.from(context).inflate(layoutResId(), parent, false))
    }

    override fun getViewType(): Int {
        return viewType.viewType()
    }
}

上述したRecycleBinderを継承したForecastViewBinderクラスでViewHolderを引数に取りViewにデータをセットしていく。

class ForecastViewBinder(private val context: Context,
                         private val forecast: Forecast) : RecycleBinder(context, ForecastViewType.FORECAST) {

    override fun layoutResId() = R.layout.forcast_binder

    override fun onCreateViewHolder(view: View): RecyclerView.ViewHolder {
        return ViewHolder(view)
    }

    override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
        viewHolder as ViewHolder

        viewHolder.date.text = DateUtils.formatDateTime(context, forecast.dt * 1000L, FORMAT_NO_YEAR)
        viewHolder.main.text = forecast.weather.get(0).main
        viewHolder.max.text = forecast.temp.max.toString()
        viewHolder.min.text = forecast.temp.min.toString()

        forecast.weather.get(0).main.let {
            viewHolder.main.text = it
            when (it.toLowerCase()) {
                "clear" -> R.drawable.ic_sun
                "clouds" -> R.drawable.ic_cloud
                "fog" -> R.drawable.ic_haze
                "light_clouds" -> R.drawable.ic_cloudy
                "light_rain" -> R.drawable.ic_rain
                "rain" -> R.drawable.ic_rain
                "snow" -> R.drawable.ic_snowing
                "storm" -> R.drawable.ic_storm
                else -> R.drawable.ic_sun // fix me
            }.let {
                viewHolder.image.setBackgroundResource(it)
            }
        }
    }
}

class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {

    val date: TextView = view.findViewById(R.id.forecast_date)
    val main: TextView = view.findViewById(R.id.forecast_weather)
    val max: TextView = view.findViewById(R.id.forecast_temp_max)
    val min: TextView = view.findViewById(R.id.forecast_temp_min)
    val image: AppCompatImageView = view.findViewById(R.id.forecast_ic)
}

FragmentからadapterのreplaceAllを呼び出す

Fragmentでadapterを生成してリスト取得したデータをreplaceAllに渡しViewを更新する。

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val context = context ?: return

        forecastsStore.forecasts()
                .observeOn(AndroidSchedulers.mainThread())
                .`as`(autoDisposable(this))
                .subscribe { forecasts ->

                    swipeRefreshLayout.isRefreshing = false

                    cityView.text = "%s/%s".format(forecasts.city.name, forecasts.city.country)
                    adapter.replaceAll(forecasts.list.map {
                        ForecastViewBinder(context, it)
                    })
                }

        savedInstanceState ?: request()

        swipeRefreshLayout.setOnRefreshListener { request() }
    }

コード

SwipeRefreshLayoutとRecycleViewを追加したプルリクエストがあるので参考にしてほしい。

Feature/improve list view by soushin · Pull Request #5 · soushin/sunshine-app · GitHub

rx-preferencesをつかってSharedPreferencesの更新をSubscribeする

ブログエントリで試しているAndroidアプリにFluxアーキテクチャを導入をしたことでデータの流れがStreamになった。SharedPreferencesも rx-preferencesをつかえば更新状態をSubscribeできるので導入過程をまとめていく。

github.com

お試し中のアプリはOpenWeatherMapのAPIを使い天気情報を取得する。ZipCodeをクエリに追加すれば地域の天気情報が取得できる。アプリ内でZipCodeを登録できるようにしたいのでSharedPreferencesで管理することにする。

rx-preferencesをつかう

rx-preferencesを追加してRepository化するまで。

# dependencies.gradle

rxPreferences = 'com.f2prateek.rx.preferences2:rx-preferences:2.0.0-RC3'
# SettingsRepository.kt

@Singleton
class SettingsRepository @Inject constructor(private val application: Application) {

    private val SETTING_NAME = "setting"
    private val ZIP_CODE_NAME = "zip_code"

    fun getZipCode() = getRxSharedPreferences(SETTING_NAME).getString(ZIP_CODE_NAME)
    fun updateZipCode(zipCode: String) = getZipCode().set(zipCode)

    private fun getRxSharedPreferences(name: String) = RxSharedPreferences.create(
            application.getSharedPreferences(BuildConfig.APPLICATION_ID + '.' + name, Context.MODE_PRIVATE))
}

Repository層の役割はデータ取得や更新など。Repositoryを使う側はその先がAPIかDBかに関心する必要させたくないのでSharedPreferencesのデータ取得や更新もRepository層で実装した。
このRepositoryをFluxアーキテクチャに乗せていく。

ActionとStoreをつくる。Dispatcherはつくらない。

# SettingsAction.kt

@Singleton
class SettingsAction @Inject constructor(private val settingsRepository: SettingsRepository) {

    fun updateZipCode(zipCode: String) = settingsRepository.updateZipCode(zipCode)
}

ActionはZipCodeを更新するメソッドを追加した。

# SettingsStore.kt

@Singleton
class SettingsStore @Inject constructor(private val settingsRepository: SettingsRepository) {
    fun zipCode() = settingsRepository.getZipCode()
            .asObservable()
            .subscribeOn(Schedulers.io())
            .toFlowable(BackpressureStrategy.LATEST)
}

StoreにはZipCodeの状態をObserveする zipCode()メソッドを追加した。

rx-preferencesは asObservable()を呼び出せばStreamオブジェクトを取得できる。BackpressureStrategy.LATESTを有効にすることで常に最新の値が流れるようにした。

Fluxアーキテクチャに乗るならばDispatcherを作るところだが、 rx-preferencesがStreamを提供してくれるのでこのエントリではDispatcherを追加していない。このアプリはまだ小さいのでDispatcherの追加はしない判断をしたが大きなアプリであればデータのアクションに応じて専用のDispatcherに乗せたほうが良い場合もあると思う。

ZipCodeの更新をSubscribeする

FragmentでZipCodeの更新をSubscribeする。

# ForecastsFragment.kt

class ForecastsFragment : AutoDisposeFragmentKotlin() {

    // ---

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

        forecastsStore.forecasts()
                .observeOn(AndroidSchedulers.mainThread())
                .`as`(autoDisposable(this))
                .subscribe { forecasts ->
                    cityView.text = "%s/%s".format(forecasts.city.name, forecasts.city.country)
                    listView.adapter = ArrayAdapter<String>(activity, android.R.layout.simple_list_item_1,
                            forecasts.list.map {
                                "%s - %s %s/%s".format(
                                        DateUtils.formatDateTime(activity, it.dt * 1000L, FORMAT_NO_YEAR),
                                        it.weather.get(0).main, it.temp.min, it.temp.max)
                            })
                }

        savedInstanceState ?: settingsStore.zipCode()
                .observeOn(AndroidSchedulers.mainThread())
                .`as`(AutoDispose.autoDisposable(this))
                .subscribe {
                    if (it.isNotBlank()) {
                        forecastsAction.findByDaily(it)
                    } else {
                        errorAction.onError("You must to set ZipCode.")
                    }
                }
    }

    // ---
}

SettingActivityでZipCodeが更新されると settingsStore.zipCode()に最新のZipCodeが流れてくるのでSubscribeをしてAPIを呼びだしている。
forecastsAction.findByDaily(it)が呼び出されれば、 forecastsStore.forecasts()に最新のAPI結果が流れてくるのでViewが切り替わる。

コード

rx-preferencesの導入前と後のコード比較ができるようにPRを残しています。 エントリで紹介したコードは断片的なので参考になれば嬉しいです。

https://github.com/soushin/sunshine-app/pull/4github.com

まとめ

  • rx-preferencesをつかってSharedPreferencesの更新をSubsribeしてみた。1つのアクションが発火されると連鎖的に複数のStreamが流動するのを体験できた。
  • まだ2つのStream(APIとSharedPreferences)だけであるが3つ,4つといったStreamが絡み合う条件などは実装が複雑になりそうである。ただRxには実装の複雑性を回避するものもあるので今後のエントリでまとめていきたい。

Dagger2 + uber/Autodispose + RxJava2でFluxアーキテクチャを導入してみた

ブログエントリで試しているAndroidアプリにFluxアーキテクチャを導入してみたのでまとめる。

Fluxアーキテクチャ

Fluxアーキテクチャに関する情報は調べるとたくさん見つかるので概要までに留めておく。

github.com

Fluxはアプリケーションデータを管理するためのデータフローのパターンで、最も重要なコンセプトはデータの流れが単一方向であること。

f:id:n_soushi:20180228092104p:plain

出典:https://github.com/facebook/flux/tree/master/examples/flux-concepts

Fluxを理解するための要素として、ActionDispatcherStoreViewがある。上記の図にもあるとおりActionからViewまでデータは一方向に流れている。ActionからDispatcherを通してStoreへデータを流す。Storeから状態変更されたデータをViewへ渡す。ViewからActionを通してDispatcherに伝達するフローはViewがonClickなどのイベントを検知して応じたActionを発火させているものである。

ViewはStoreの状態変更の検知やActionを発火させる役割を担う。ViewはStoreの状態変更があれば即座に対応したり、イベントが起きたらActionへ伝えActionからDispatcherを通してStoreの状態変更を検知するためにPub/Subを行う必要がある。ActionとStoreを仲介するDispatcherにStreamを導入することでデータのPub/Subを実現する。このエントリではRxJava2を採用した。

ここからはFluxアーキテクチャをAndroidアプリに導入する過程のコードをまとめていく。

Dispatcher

class ForecastsDispatcher {
    val forecastsProcessor = PublishProcessor.create<Forecasts>()
}

PublishProcessorでFlowableなPublisherを定義する。forecastsProcessorは天気情報を保持する。

Action

@Singleton
class ForecastsAction @Inject constructor(private val forecastsDispatcher: ForecastsDispatcher,
                                          private val errorDispatcher: ErrorDispatcher,
                                          private val openWeatherMapRepository: OpenWeatherMapRepository) {
    fun findByDaily() {
        openWeatherMapRepository.findForecastByDaily()
                .subscribeOn(Schedulers.io())
                .subscribe({
                    forecastsDispatcher.forecastsProcessor.onNext(it)
                }, {
                    errorDispatcher.onError(Err(it.message))
                })
    }
}

openWeatherMapRepositoryを通して天気情報を取得してforecastsDispatcher.forecastsProcessor.onNext(it)でStreamにデータを流す。

Store

@Singleton
class ForecastsStore @Inject constructor(private val forecastsDispatcher: ForecastsDispatcher) {
    fun forecasts() = forecastsDispatcher.forecastsProcessor
}

Storeはデータの状態を保持する。またSetterは定義せずデータ取得のメソッドを定義する。forecasts()はdispatcherを通して最新の情報を取得する。

View

class ForecastsFragment : AutoDisposeFragmentKotlin() {

    @Inject lateinit var forecastsAction: ForecastsAction
    @Inject lateinit var forecastsStore: ForecastsStore
    @Inject lateinit var errorStore: ErrorStore

    // ---

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

        forecastsStore.forecasts()
                .observeOn(AndroidSchedulers.mainThread())
                .`as`(autoDisposable(this))
                .subscribe { forecasts ->
                    listView.adapter = ArrayAdapter<String>(activity, android.R.layout.simple_list_item_1,
                            forecasts.list.map {
                                "%s - %s %s/%s".format(
                                        DateUtils.formatDateTime(activity, it.dt * 1000L, FORMAT_NO_YEAR),
                                        it.weather.get(0).main, it.temp.min, it.temp.max)
                            })
                }

        savedInstanceState ?: forecastsAction.findByDaily()
    }

    // ---

ActionのforecastsAction.findByDaily()を実行して、forecastsStore.forecasts()で購読処理をする。

autoDisposable

ライフサイクルに合わせてStreamを廃棄させたい。 forecastsStore.forecasts()でStreamを購読しているが他のActivityやFragmentに切り替わってもStreamのインスタンスを保持し続けるとメモリリークを起こす原因となるため不要なStreamを廃棄する必要がある。

そこでuber/AutoDisposeを導入すれば、Streamの廃棄をAndroidのライフサイクルに合わせて自動で行ってくれる。

github.com

abstract class AutoDisposeFragmentKotlin : Fragment(), LifecycleScopeProvider<AutoDisposeFragmentKotlin.FragmentEvent> {

    // ---

    companion object {

        /**
         * This is a function of current event -> target disposal event. That is to say that if event A
         * returns B, then any stream subscribed to during A will autodispose on B. In Android, we make
         * symmetric boundary conditions. Create -> Destroy, Start -> Stop, etc. For anything after
         * Resume we dispose on the next immediate destruction event. Subscribing after Detach is an
         * error.
         */
        private val CORRESPONDING_EVENTS: Function<FragmentEvent, FragmentEvent> =
                Function { lifecycleEvents ->
            when (lifecycleEvents) {
                ATTACH -> DETACH
                CREATE -> DESTROY
                CREATE_VIEW -> DESTROY_VIEW
                START -> STOP
                RESUME -> PAUSE
                PAUSE -> STOP
                STOP -> DESTROY_VIEW
                DESTROY_VIEW -> DESTROY
                DESTROY -> DETACH
                else -> throw LifecycleEndedException("Cannot bind to Fragment lifecycle after detach.")
            }
        }
    }
}

Streamの購読を開始したイベントとStreamを廃棄させるイベントをマッピングしているコードが上記である。このコードはuber/AutoDisposeのrecipeを参考にした。

AutoDisposeを使えばCREATEで購読を開始したStreamをDESTROYで廃棄してくれる。

ErrorDispatcher

APIエラーが起きた場合のエラーをViewまで伝達させるためにErrorDispatcherを定義した。

class ErrorDispatcher {
    val errors = PublishSubject.create<Err>().toSerialized()

    fun onError(err: Err) = errors.onNext(err)
}

エラーはBackpressureは必要ないのでPublishSubjectを定義してシリアライズしている。

ErrorStoreとView

@Singleton
class ErrorStore @Inject constructor(private val errorDispatcher: ErrorDispatcher) {
    fun errors() = errorDispatcher.errors
}

ErrorStoreを定義することでViewから最新のエラーを取得することができる。

class ForecastsFragment : AutoDisposeFragmentKotlin() {

    @Inject lateinit var forecastsAction: ForecastsAction
    @Inject lateinit var forecastsStore: ForecastsStore
    @Inject lateinit var errorStore: ErrorStore

    // ---

    override fun onResume() {
        super.onResume()

        errorStore.errors()
                .observeOn(AndroidSchedulers.mainThread())
                .`as`(autoDisposable(this))
                .subscribe { error ->
                    Toast.makeText(activity, error.message, Toast.LENGTH_LONG).show()
                }
    }

    // ---
}

コード

Fluxアーキテクチャの導入前と後のコード比較ができるようにPRを残しています。 エントリで紹介したコードは断片的なので参考になれば嬉しいです。

github.com

まとめ

  • Fluxアーキテクチャを構成する要素であるAction、Dispatcher、Store、Viewを理解してコード化してみた。
  • コード化すると各要素の責務が明確になる。明確になるということはコード運用に置いてPullRequestの注視ポイントも明確になると思う。
  • Fluxアーキテクチャのコンセプトである単一方向のデータフローはコード化するとより理解が深まった。
  • View(Fragment)のコードをシンプルに構成してくれているのはRxJavaの恩恵でありFluxアーキテクチャとStreamのマッチングは切り離せないものである。

Dagger2(android support module)をつかってFragmentにDIする

Dagger2(android support module)をつかってFragmentにDIする方法をまとめていく。

HasSupportFragmentInjectorを実装する

MainActivityにHasSupportFragmentInjectorを継承させ必要な実装をします。

class MainActivity : AppCompatActivity(), HasSupportFragmentInjector {

    @Inject
    lateinit var androidInjector: DispatchingAndroidInjector<Fragment>

    override fun supportFragmentInjector() = androidInjector

    override fun onCreate(savedInstanceState: Bundle?) {
        AndroidInjection.inject(this)
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        setContentFragment(R.id.mainLayout)
    }

    private fun setContentFragment(containerViewId: Int) {
        supportFragmentManager.let { manager ->
            manager.findFragmentById(containerViewId)?.let { return }
            ForecastsFragment.newInstance().apply {
                manager?.beginTransaction()?.add(containerViewId, this)?.commit()
            }
        }
    }
}

Fragmenのモジュールをまとめる

Fragmentで管理するモジュールを MainModuleとしてまとめる。

@Module
internal abstract class MainModule {

    @ContributesAndroidInjector
    abstract fun contributeMainFragment(): ForecastsFragment

}

MainActivityのSubcomponentにMainModuleを追加する。

@Module
internal abstract class UiModule {

    @ContributesAndroidInjector(modules = [MainModule::class])
    internal abstract fun contributeMainActivity(): MainActivity
}

今後、MainActivityに機能が追加する場合は、MainModuleに依存するモジュールを追加していく。

Fragmenのライフサイクルに合わせてInject

サンプルのコードではonAttachでInjetctをしている。

class ForecastsFragment : Fragment() {

    @Inject
    lateinit var openWeatherMapRepository: OpenWeatherMapRepository

    private var listView: ListView by Delegates.notNull()

    override fun onAttach(context: Context?) {
        AndroidSupportInjection.inject(this)
        super.onAttach(context)
    }

    override fun onCreateView(inflater: LayoutInflater?, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        val view = inflater?.inflate(R.layout.forcasts_fragment, container, false) ?: return null
        listView = view.findViewById<ListView>(R.id.list_view)
        return view
    }

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

        openWeatherMapRepository.findForecastByDaily()
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe { forecasts ->
                    listView.adapter = ArrayAdapter<String>(activity, android.R.layout.simple_list_item_1,
                            forecasts.list.map {
                                "%s - %s %s/%s".format(
                                        DateUtils.formatDateTime(activity, it.dt * 1000L, FORMAT_NO_YEAR),
                                        it.weather.get(0).main, it.temp.min, it.temp.max)
                            })
                }
    }

    companion object {
        fun newInstance() = ForecastsFragment()
    }
}
  • Fragmentの理解は公式のドキュメントが分かりやすい。
  • Fragmentを部品と捉えてタブレットとハンドセットで異なるレイアウトでも部品(Fragment)を組み合わせることで柔軟に対応することができるし部品の再利用も容易にできる。

コード

今回のコードはこちらのPRにまとまっていますので参考になれば嬉しいです。

github.com

Dagger2 (android support module)とretrofit2をつかってAPIレスポンスをListViewで表示する

掲題のとおりAndroidのListViewを表示してみる。 APIリクエストは retrofitを使い天気情報を取得できるOpenWeatherMapのAPIを利用する。

github.com

DIにはDaggerを使い、2.11から有効なandroid support moduleを利用する。

github.com

APIをリクエストするServiceクラスをつくる

interface OpenWeatherMapService {

    @GET("/data/2.5/forecast/daily?q=94043&mode=json&units=metric&cnt=7&APPID=XXXXX")
    fun findForecastByDaily(): Observable<Forecasts>
}
  • レスポンスの型はObservable<Forecasts>。型パラメータのForecastsは Parcelableを実装したDTO。
  • APPID=XXXXXはopenweathermapから取得したID

このServiceクラスをRepositoryクラスから呼び出し見通しの良いコードにするためにDIを利用していく。DIについては後述する。

Parcelableを実装したDTO(data class)

data class Forecasts(var cod: Int, var list: List<Forecast>) : Parcelable {

    constructor(src: Parcel) : this(
            cod = src.readInt(),
            list = src.createTypedArrayList(Forecast.CREATOR)
    )

    // -
}

ActivityやFragmentにパラメータを渡すために Parcelableを実装したdata classを用意する。 フィールドにプリミティブ型ではないオブジェクト型を使う場合は次のようにする。

data class Forecast(var dt: Long, var temp: Temp, var weather: List<Weather>) : Parcelable {

    constructor(src: Parcel) : this(
            dt = src.readLong(),
            temp = src.readParcelable(Temp::class.java.classLoader),  //  ← data class `Temp`
            weather = src.createTypedArrayList(Weather.CREATOR) //  ← List型のパラメータに data class `Weather`
    )

    override fun describeContents(): Int {
        return 0
    }

    override fun writeToParcel(dest: Parcel?, flags: Int) {
        dest?.writeLong(dt)
        dest?.writeParcelable(temp, flags) //  ← data class `Temp`
        dest?.writeList(weather) //  ← List型のパラメータに data class `Weather`
    }

    // -
}

MainActivityでDIする

MainActivityでOpenWeatherMapServiceを提供するRepositoryクラスをInjectするまでの過程をまとてめていく。

RepositoryModuleをつくる

class OpenWeatherMapRepository(val openWeatherMapService: OpenWeatherMapService) {
    fun findForecastByDaily() = openWeatherMapService.findForecastByDaily()
}
@Module
internal object RepositoryModule {

    @Provides
    @Singleton
    @JvmStatic
    fun provideOpenWeatherMapRepository(openWeatherMapService: OpenWeatherMapService) =
            OpenWeatherMapRepository(openWeatherMapService)
}

OpenWeatherMapRepositoryを提供するRepositoryModuleをつくった。

DataModuleをつくる

RepositoryModuleをIncludeしたDataModuleをつくる。このモジュールでRetrofitクライアントをビルドする。

@Module(includes = arrayOf(RepositoryModule::class))
internal object DataModule {

    @Provides
    @Singleton
    @JvmStatic
    fun provideMoshi() = Moshi.Builder()
            .add(KotlinJsonAdapterFactory())
            .build()

    @Provides
    @Singleton
    @JvmStatic
    fun provideOkHttp(): OkHttpClient = OkHttpClient.Builder()
            .build()

    @Provides
    @Singleton
    @JvmStatic
    fun provideRetrofit(oktHttpClient: OkHttpClient, moshi: Moshi): Retrofit = Retrofit.Builder()
            .client(oktHttpClient)
            .baseUrl("http://api.openweathermap.org")
            .addConverterFactory(MoshiConverterFactory.create(moshi))
            .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
            .build()

    @Provides
    @Singleton
    @JvmStatic
    fun provideOpenWeatherMapService(retrofit: Retrofit) = retrofit.create(OpenWeatherMapService::class.java)
}
  • JSONパーサにはKotlinサポートが入っているMoshiをつかう

@ContributesAndroidInjectorをつかいMainActivityへのInjectを定義する

Dagger 2.11の重要ポイントの1つ。ActivityへのInjectは @ContributesAndroidInjectorをつかいUiModuleをつくる。

@Module
internal abstract class UiModule {

    @ContributesAndroidInjector
    internal abstract fun contributeMainActivity(): MainActivity
}

Activityが増えたときには、ここにActivityへのInjectを追加する。

ApplicationComponentに AndroidInjector<KotlinApplication> を継承させる

Dagger 2.11の重要ポイントの1つ。ApplicationクラスへInjectさせるためにApplicationComponentに AndroidInjector<KotlinApplication> を継承させる。後述するApplicationの親クラスに dagger.android.support.DaggerApplicationを使うためmoduleにAndroidSupportInjectionModule を追加する。

@Singleton
@Component(modules = arrayOf(AndroidSupportInjectionModule::class,
        AppModule::class,
        DataModule::class,
        UiModule::class))
interface ApplicationComponent : AndroidInjector<KotlinApplication> {

    @Component.Builder
    interface Builder {
        @BindsInstance
        fun application(application: KotlinApplication): Builder
        fun build(): ApplicationComponent
    }

    override fun inject(application: KotlinApplication)
}

Applicationクラスに DaggerApplicationを継承させ実装する

HasActivityInjectorを継承する流れを紹介するエントリもあるが DaggerApplicationHasActivityInjectorの実装が含まれているのでこちらをつかう。

class KotlinApplication : DaggerApplication() {

    override fun applicationInjector() = DaggerApplicationComponent.builder()
            .application(this)
            .build()

    override fun onCreate() {
        super.onCreate()
    }
}

MainActivityにOpenWeatherMapRepositoryをInjectする

最後にMainActivityにOpenWeatherMapRepositoryをInjectする。Dagger 2.11の重要ポイントの1つ。InjectするためにはonCreateでAndroidInjection.inject(this)を呼び出す。

class MainActivity : AppCompatActivity() {

    @Inject lateinit var openWeatherMapRepository: OpenWeatherMapRepository

    override fun onCreate(savedInstanceState: Bundle?) {
        AndroidInjection.inject(this)

    // -
}

APIレスポンスをListViewに表示する

class MainActivity : AppCompatActivity() {

    @Inject lateinit var openWeatherMapRepository: OpenWeatherMapRepository

    override fun onCreate(savedInstanceState: Bundle?) {
        AndroidInjection.inject(this)
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        openWeatherMapRepository.findForecastByDaily()
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe { forecasts ->
                    findViewById<ListView>(R.id.listview).let { view ->
                        view.adapter = ArrayAdapter<String>(this, android.R.layout.simple_list_item_1,
                                forecasts.list.map {
                                    "%s - %s %s/%s".format(
                                    DateUtils.formatDateTime(this, it.dt * 1000L, FORMAT_NO_YEAR),
                                            it.weather.get(0).main, it.temp.min, it.temp.max)
                                })
                    }
                }
    }
}

ListのItemViewには simple_list_item_1をつかって日にちと最高気温と最低気温を表示している。

まとめ

  • Daggert2(android support module)のDIをまとめた。android support module以前のDI方法だとコピペコードが増える懸念があり登場した経緯を知ってなるほど、と思った。
  • retrofitはシンプルな使い方までに留まっているので引き続き触っていきながら知見をまとめていきたい。

コード

このエントリまでのコードがPull Requestにまとまっていますので参考になれば嬉しいです。(初回のコミットなので不要なlayoutコードなどが散見してます。)

github.com

参考