Android
Kotlin
Retrofit
DataBinding

最近のAndroidアーキテクチャを勉強するために、はてブアプリを開発した(アーキテクチャ編)

最近のAndroidアーキテクチャを勉強するために、はてブアプリを開発した(選定編)の中でここではアーキテクチャ部分を解説したいと思います。

これらの記事で紹介したものでAndroidアプリを作ってみました。Google Playストアで公開中です。
HotBook - はてブ記事がサクサク読める

今回は以下の2つをざっくり使ってみて、使い方や所感等を書いていきます。
- Android Architecture Components
- Data Binding

Android Architecture Components

公式ページ

Android Architecture Componentsには4つのコンポーネントがあります。

  • Lifecycles : Androidの新しいライフサイクルを提供する
  • LiveData : Lifecyclesに合わせて動くObserverパターンを実装できる
  • ViewModel : Lifecyclesに合わせて動くデータ保持モデルを実装できる
  • Room : Lifecyclesに合わせて動くSQLiteクライアント

詰まるところAndroidの旧ライフサイクルではなくGoogleが提案している新しいLifecyclesに合わせて実装ができるようにしたComponentの集まりとなっています。
今回はRoomを使いませんでしたので、ここでは上の3つについて紹介します。

Lifecycles

一言でいうと、Lifecycles単体ではあんまり開発者に恩恵がないです・・・。

これまでのAndroidのライフサイクルはハードウェアやAndroid側の都合に合わせて動くものでした。開発者はそれぞれの状態に合わせて適切に実装する必要がありました。

例えば画面回転をするだけでActivityやFragmentが再生成され、画面がリセットされてしまったり、非同期通信中にActivityがなくなってしまったときにコールバックを受け取れなくなってしまいアプリが落ちるといったことがないよう、常に意識しながら開発しなければなりませんでした。

新しいLifecyclesを使うとより開発者側に寄り添ったライフサイクルで実装することができるようになります。

Lifecycles

これは公式ページに載っている図ですが、onCreateで始まりonDestoryで終わるAndroidライフサイクルをイベントとしてLifecyclesが吸収し、代わって開発者はそれぞれのstatesに対して実装するというのがLifecyclesになります。

このLifecyclesを単体で使うこともできますが、図で見てもほとんど元々あったAndroidのライフサイクルと差があるようには見えませんよね💦
しかしこのLifecyclesの仕組みをうまく使ったLiveDataやViewModelと組み合わせることで真価を発揮することができます。

なのでここではLifecyclesというのがあるんだ、程度の認識で良いと思います。

Lifecycles単体で組み込む方法はbuild.gradleにライブラリを追加するだけで完了です。
kotlinの場合はkaptを有効にする必要があります。

build.gradle
apply plugin: 'kotlin-kapt'

dependencies {
  implementation 'android.arch.lifecycle:runtime:1.1.0'
  kapt 'android.arch.lifecycle:compiler:1.1.0'
}

使い方は公式ページに紹介されています。

LiveData

一言でいうと、非同期処理を行う時の困りごとのほとんどが解決されます!

まずはアプリで実際にLiveDataを使っているコードを紹介します。

EntryActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
    ...

    // コメントの表示、非表示の切り替え
    viewModel.showBookmark.observe(this, switchComments)
}

/** コメント一覧の表示・非表示を切り替える */
private val switchComments = Observer<Boolean> {
    val ft = supportFragmentManager.beginTransaction().apply {
        val bookmarkFragment = supportFragmentManager.findFragmentByTag("bookmark")
        if (it == true) {
            show(bookmarkFragment)
            setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN)

        } else {
            hide(bookmarkFragment)
            setTransition(FragmentTransaction.TRANSIT_FRAGMENT_CLOSE)
        }
    }
    ft.commit()
}

これはブコメの表示・非表示を切り替えている箇所の実装になります。
まず上から見ていきましょう。

viewModel.showBookmark.observe(this, switchComments)

viewModel内のshowBookmarkはMutableLivedataという、オブジェクトをLiveDataに対応させるためのものになります。

EntryViewModel.kt
var showBookmark = MutableLiveData<Boolean>()

このshowBookmarkに対して値をセットすることでイベントが伝搬し、Observerが受信することができます。

さてここまでは普通のObserverパターンですが、先ほども書いた通りObserverが受け取る前にActivityがなくなってしまった場合はどうなるのでしょうか。

なんと、Observerは反応しないようになっているのです!
そして、受け取れるようになっていれば自動的にObserverが受け取ります!

つまりObserverの中は必ずActivityが生きている状態で処理を書くことができます。
どうしてそんなことが・・・と思われるでしょうが、これがLifecyclesのすごいところなのです。

これまでは非同期中もライフサイクルを考慮する必要がありましたが、Lifecyclesによって隠蔽され、statesのみで良いと説明しました。
つまりSTARTEDの状態であればObserverが反応し、それより前のstateになった場合はObserverが反応しないというシンプルなアーキテクチャになるのです。

加えて、LiveDataに値をセットするのはUIスレッドでない所からでもできるという仕様になっているため、非同期処理の結果をLiveDataに渡すだけで、Observer内で受け取った結果をもとにUI処理をすることができます。
なので例ではObserver内で堂々とFragmentの操作をしていますが、問題ありません。

どう使うのがよいか

HTTP通信の結果をLiveDataに渡すというのが基本的な使い方になるでしょう。
ただ結果をそのままダイレクトにUIに反映させるのは後述のDataBindingのほうが向いています。

私はRetrofitの通信結果をLiveDataで渡すという使い方をしています。LiveDataとRetrofitを組み合わせるラッパークラスを作ってみました。

Retrofit 💛 LiveData for Android - GitHub

data class ResponseBody<T>(
        var body: T?,
        var error: ResponseError?
)

data class ResponseError(
        var statusCode: Int,
        var message: String
)

class RetrofitLiveData<T>(private val call: Call<T>, private var retry: Int = 3) : LiveData<ResponseBody<T>>(), Callback<T> {
    override fun onActive() {
        if (!call.isCanceled && !call.isExecuted) call.enqueue(this)
    }

    override fun onFailure(call: Call<T>?, t: Throwable) {
        Log.e("Response Error", "retry $retry", t)

        retry -= 1
        if (retry > 0) {
            call?.clone()?.enqueue(this)
        } else {
            val err = ResponseError(0, t.localizedMessage ?: "")
            postValue(ResponseBody(null, err))
        }
    }

    override fun onResponse(call: Call<T>?, response: Response<T>?) {
        try {
            if (response != null) {
                if (response.isSuccessful) {
                    postValue(ResponseBody(response.body(), null))
                } else {
                    val err = ResponseError(response.code(), response.errorBody()?.string() ?: "")
                    Log.w("Response Error", err.message)
                    postValue(ResponseBody(null, err))
                }
            }
        } catch (e: Exception) {
            val err = ResponseError(0, e.localizedMessage ?: "")
            postValue(ResponseBody(null, err))
        }
    }

    fun cancel() {
        if (!call.isCanceled) {
            call.cancel()
        }
    }
}

なぜResponseBodyクラスを作る必要があるのかというと、LiveDataは1つしか値を渡すことができず、RxのようにsubscribeしたときにonNextとonErrorと分けて受け取ることができないからです。なので、成功と失敗両方を渡せるResponseBodyを返すようにしています。

使い方ですが、RetfofitLiveDataのコンストラクタにCallを返すRetrofitの関数を渡せばOKです。

interface Api {
    @GET("/entry/jsonlite/")
    fun getComments(@Query("url") url: String): Call<CommentResponse>
}
val api = retrofit.create(Api::class.java)
RetrofitLiveData(api.getComments(url)).observe(owner, Observer { response ->
    response?.body?.let {
        // 成功
    }
    response?.error?.let {
        // 失敗
    }
}

ViewModel

一言でいうと、データの値のを簡単に保持することができるようになります!

こちらも実際に使っているコードを紹介します。

EntryViewModel.kt
class EntryViewModel(app: Application): AndroidViewModel(app) {
    fun getBookmarks(owner: LifecycleOwner, url: String) {
        ...
    }
}
EntryListFragment.kt
private lateinit var viewModel: EntryListViewModel

override fun onActivityCreated(savedInstanceState: Bundle?) {
    super.onActivityCreated(savedInstanceState)
    viewModel = ViewModelProviders.of(this).get(EntryListViewModel::class.java)
}

このようにViewModelを初期化して使います。あとは普通のオブジェクトとして扱えます。

ViewModelProvidersで初期化するにはViewModelAndroidViewModelを継承します。AndroidViewModelの場合はApplicationをコンストラクタとして渡せるので、Contextが欲しい時はAndroidViewModelにしましょう。

普通にViewModelを用意するのとは違って、こちらを使うと2つのメリットが得られます。

画面回転時も値を保持し続けてくれる

この方法で初期化したViewModelは画面回転時も値を引き継ぐことができます。これまではsavedInstanceStateを使っていましたが、ViewModelProvidersがよしなにしてくれます。

ActivityとFragmentで値を共有できる

ViewModelProviders.of()で渡すものによってViewModelのスコープが決定します。Activityを渡せばそのActivityで使えますし、Fragmentと同じです。
しかし、Fragmentで親Activityのインスタンスを渡せばActivity内のスコープとなるため、ActivityとFragmentで同じViewModelを操作することができます。もちろん同じActivity内のFragment同士でもOKです。

viewModel = ViewModelProviders.of(activity!!).get(EntryListViewModel::class.java)

これまでは値を共有するためにコールバックを書いたりしていましたが、ViewModelが橋渡しとして使えるのは非常に良いですね。

Data Binding

TextViewをfindViewByIdしてsetText・・・なんてことをする必要がもうなくなりました。(うまく使えば)

AndroidとfindViewByIdは切っても切れない関係にありました。しかしその使い勝手の悪さから開放されたい開発者は多かったでしょう。ButterKnifeはまさにそれを解決するためのライブラリでした。
今回それを公式で解決できるようになったのは非常にうれしいことです。

有効にするには

build.gradleに以下を追加するだけです。

build.gradle
android {
    dataBinding {
        enabled = true
    }
}

DataDindingを使いたいレイアウトXMLで<layout>というタグをrootにすることでそのレイアウトにDataBindingが適用されます。

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="?android:attr/colorBackground"
        android:scrollbars="vertical">

    </android.support.v7.widget.RecyclerView>
</layout>

ここで一度ビルドをかけてください。ビルドが走るとDataBinding用のクラスが自動生成されます。
最後にXMLを読み込んでいた箇所をDataBindingのクラスから読み込むように置き換えたら準備完了です。

Activity.kt
private lateinit var binding: ActivityMainBinding // クラス名はXMLのファイル名+Bindingとなります

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
    ...

Fragmentの場合は以下のような感じです。

Fragment.kt
private lateinit var binding: FragmentBookmarkListBinding

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

使い方1・ButterKnife代わりとして使う

面倒なfindViewByIdを肩代わりしてくれるButterknifeですが、実はDatabindingで代用が可能です。

XMLで各ViewにIDを振っている場合はそれがfindViewByIdされた状態でDataBindingのクラスに格納されています!

例えば上のRecyclerViewのある上のXMLの例の場合、IDがrecycler_viewなので、bindingの中にrecyclerViewがあり、そのRecyclerViewを操作することができます。

Fragment.kt
private lateinit var binding: FragmentBookmarkListBinding

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

override fun onActivityCreated(savedInstanceState: Bundle?) {
    super.onActivityCreated(savedInstanceState)
    binding.recyclerView.layoutManager = LinearLayoutManager(context) // 問題ナシ
    ...

(XMLのIDがスネークケースの場合、プログラム側ではキャメルケースに変換してくれます)

このButterKnifeとして使うだけでも実はButterKnife以上のメリットがあります。

  1. XMLの中にないIDでfindViewByIdしてしまうという事故が発生しなくなる
  2. 型指定の間違いも発生しなくなる

これだけでも非常にありがたいですね。

使い方2・画面描画処理をより簡潔にする

DataBindingのもう一つの機能は、XMLの中で値のset/getを設定することができるようになります。
例えばTextViewにsetTextしたり、ViewのvisibilityをBoolean値で自動的に切り替えるといったことを、今まではプログラム側でやる必要があったものが、XMLのなかでできるようになります。

<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="text"
            type="String" />
    </data>

    <TextView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:text="@{text}" />
</layout>

これをビルドするとIDの時と同様、bindingの中にtextが自動で追加されます。
そのtextに値を入れるとなんとtextの中身が表示されるのです。

// textView.setText("hogehoge") // これまでこうしていたのが
binding.text = "hogehoge" // これだけでOK

また、値によってこのViewは表示する・非表示にするという切り替えはよくやると思いますが、それもXML側に書くことができます。

<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>
        <import type="android.view.View" />

        <variable
            name="loading"
            type="Boolean" />
    </data>

    <TextView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:text="Please wait..."
        android:visibility="@{loading ? View.VISIBLE : View.GONE}" />
</layout>

こうすることでloadingがtrueの時に限りTextViewを表示させることができます。
プログラム側ではフラグによって各Viewを操作する必要がなくなり、フラグの値によってViewが自動的にふるまうということができるようになります。

もう「読み込み中」のTextViewを消し忘れた!なんてことがなくなるわけですね。

使い方3・ViewModelと組み合わせて画面回転に対応する

裏技感がありますが、DataBindingの中にViewModelそのものをvariableとして指定することで画面回転に簡単に対応することができます。

<layout>
    <data>

        <variable
            name="viewModel"
            type="jp.kuluna.example.models.MainViewModel" />
    </data>

    <TextView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:text="@{viewModel.text}" />
</layout>
Activity.kt
private lateinit var binding: ActivityMainBinding

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
    binding.viewModel = ViewModelProviders.of(this).get(MainViewModel::java.class)
}

最後に

昔と比べてビジネスロジックに集中しやすいアーキテクチャになっており非常に良いと感じました。Google公式からの提供という安心感もあります。

長々と書きましたが、初めのうちは私もこれらのアーキテクチャを使いこなせずにいました。
まずは○○だけ、次に○○だけ・・・という風に重ねていくうちに少しずつ理解できるようになり今まで来た感じです。

しかしDataBindingはもっと早く知りたかったなぁと(笑)