aridai.NET
Androidのサンプルアプリを書いてみた

書いたもの

最近はAndroidアプリを書いていて、適当なサンプルアプリができたので、ちょいとここらで記事にします。

GitHubのリポジトリは AndroidSampleAppHokurikuConnpass です。

基本的にこれらのコードは2019年3月時点での書き方になっていると思います。
Support Library から AndroidX に移行している時期だったり、Android Architecture Components の目玉機能である LiveData双方向データバインディング に対応し始めたり、Kotlin Coroutines の正式版がリリースされたりしている時期のようです。

ここ数年のWindowsの WPF 界隈は、基本的に ReactivePropertyPrism を使った MVVM + Messenger + Behavior パターンによるスタイルに落ち着いているイメージがありますが、Android界隈はまだまだ進化の途中という感じがしてアツいですね。

私もAndroidはまったくの初心者なので、いろいろと手探りで勉強している状態ですが、とりあえずそれっぽい書き方ができたと思います。
ただし、まだ Kotlin Coroutines は完全には理解できていないので、レイヤ間のつなぎのコールバックとして RxJava を使っています。

AndroidSampleAppについて

名前の通り、Androidのサンプルアプリです。
画面はこんな感じになっています。

AndroidSampleAppのデモ

コードは Android Architecture Blueprintstodo-mvvm-live-kotlin ブランチを参考にしています。

このリポジトリで学べること

このリポジトリのコードを見ることで、次のことを学ぶことができます。

  • Kotlin
  • AndroidX
  • DataBinding
  • Model-View-ViewModel (MVVM)
  • Android Architecture Components (AAC)
    • Lifecycle
    • LiveData
    • ViewModel
  • RxJava

サンプルアプリのコード解説

ActivityとFragmentの構成

このプロジェクトでは、ActivityFragment を次のように構成しています。

  • メイン画面
    • MainActivity (activity_main.xml)
    • MainFragment (fragment_main.xml)
  • サブ画面
    • SubActivity (activity_sub.xml)
    • A画面
      • SubAFragment (fragment_sub_a.xml)
    • B画面
      • SubBFragment (fragment_sub_b.xml)
  • 文字列入力画面
    • InputFormActivity (activity_input_form.xml)
    • InputFormFragment (fragment_input_form.xml)

基本的に、Activityはナビゲーションドロワとアクションバー、フローティングアクションボタンを持たせる画面の外側的な役割、Fragmentは各々の画面を構成する役割といった感じに役割を持たせています。
私も初めてAndroidアプリを書いたのでそこまで自信はありませんが、参考にしたリポジトリがそんな感じだったのでそれを真似した感じです。
ただ、タブレット対応をするとなったときに、画面のパーツをさらに細かくFragmentに分けたり、ナビゲーションドロワもFragmentにしてしまうみたいな構成も考えられますね。

Fragmentの初期化

このアプリではFragmentでDataBindingを使っています。
そのFragmentのコードはこんな感じになります。

class MainFragment : Fragment() {

    private lateinit var dataBinding: FragmentMainBinding

    private val viewModel: MainViewModel by lazy {
        ViewModelProviders.of(this.activity as FragmentActivity).get(MainViewModel::class.java)
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        this.dataBinding = FragmentMainBinding.inflate(inflater, container, false).also {
            it.viewModel = this.viewModel
            it.lifecycleOwner = this.viewLifecycleOwner
        }

        return this.dataBinding.root
    }
}

データバインディングを使っていると自動的に FragmentMainBinding というクラスが生成されます。
Viewの初期化 (xmlとの紐付け?) はこのクラスの inflate() を使います。
そして、Binding用オブジェクトに viewModellifecycleOwner を設定します。
viewModel はxml側で下のようなコードを書いたときに付けた変数名です。

<layout ...>
    <data>
        <variable
            name="viewModel"
            type="net.aridai.androidsampleapp.viewmodels.InputFormViewModel"/>
    </data>
    ...
</layout>

lifecycleowner の設定はデータバインディングでLiveDataを使っていると必須です。
確か、これを書き忘れると値の書き換えが反映されなかった気がします。
また、これはActivityとFragmentで書き方が違っていて、Activityのときは it.lifecycleOwner = this@MyActivity で、Fragmentのときは it.lifecycleOwner = this.viewLifecycleOwner とする必要があります。
どうやらFragment自身のライフサイクルとFragmentのViewのライフサイクルが異なるみたいです。
参考: FragmentとgetViewLifecycleの話 - stsnブログ

DataBindingについて

ViewModelのLiveDataをxml側で書くだけで、自動的に単方向・双方向バインディングをしてくれます。

単方向バインディングの例
ViewModel側: val imageUri = MutableLiveData<Uri>()
xml側: android:src="@{viewModel.imageUri}"

双方向バインディングの例
ViewModel側: val text = MutableLiveData<String>()
xml側: android:text="@={viewModel.text}"

LiveDataには Transformations というものがあり、例えば「他のLiveDataの変更通知のタイミングで値を作る」といったこともできます。

class InputFormViewModel : ViewModel() {

    val announceText: LiveData<String> by lazy {
        Transformations.map(this.text) {
            if (it == "サブネミミッミ") "ミミッミ!"
            else "文字列を入力してください。"
        }
    }

    val pugVisibility: LiveData<Int> by lazy {
        Transformations.map(this.announceText) {
            when (it) {
                "ミミッミ!" -> View.VISIBLE
                else -> View.GONE
            }
        }
    }

    val text = MutableLiveData<String>().also {
        it.value = "文字列を入力してちょ!"
    }
}

この例では、入力フォームのテキストボックスに「サブネミミッミ」という文字列が入力されると、「文字列を入力してください。」というアナウンス表示が「ミミッミ!」に変わり、サブネミミッミの画像が表示されるようになります。
(デモ画面のGIFでも確認できると思います。)

announceTextTransformations.map() で生成されていて、text の変更のタイミングで、その値によって返す文字列を切り替えています。
pugVisibilityTransformations.map()announceText を監視していて、この文字列の変更に応じて、表示・非表示を切り替えています。
(ImageViewvisibility にバインドしています。)

HokurikuConnpassについて

このアプリは connpass のAPIを叩いて、北陸3県で開催されるイベントを表示したかったクライアントアプリです。
詳しくは README.md を見てください。

アプリ自体は未完成ですが、コードを書くいい勉強になったので載せておきます。

アプリの設計など

一応、Clean Architecture 風に作っています。
ちゃんと勉強したわけではないのですが、Qiita記事や他の人のリポジトリを見ながら、見よう見まねでパッケージ分けなどを似せてみた感じです。

上のサンプルアプリに加えて以下が学べると思います。

  • MVVM + Clean Architecture っぽいアーキテクチャ
  • Android Architecture Components
    • Room
  • Retrofit
  • Dagger
  • LeakCanary
  • テスト (Unit / Instrumented)

軽いコード解説

RxJavaについて

冒頭でも触れましたが、まださすがに Coroutines で書けなかったので、データの伝搬に RxJavaSingle<T>Completable を使っています。
例えばリポジトリ層はこんな感じです。

interface EventRepository {

    fun update(): Completable

    fun getEvents(prefectures: Set<Prefecture>): Single<List<Event>>
}

これを実際に叩いている部分は次のような感じです。
(リポジトリを直接触るのではなく、ユースケースをかませてあります。)

private fun onFetchRequested(prefs: Set<Prefecture>) {
    this.getEventsUseCase.execute(prefs)
        .subscribeOn(Schedulers.io())
        .subscribeBy(onError = this::onErrorOccurred) {
            this._events.postValue(it)
        }
}

Rxなので subscribeOn()observeOn() が使えます。
ですから、ここでスケジューラを指定してやれば、別スレッドに投げるといったことも簡単にできちゃいます。
ただ、非同期用途だけならば Coroutines の方がより直感的だと思いますが。
(Rxは非同期用途以外にオペレータを用いた複雑なデータの伝搬などができるという利点があるので「Rx vs. async/await・Kotlin Coroutines」みたいな比較は不毛だと思っています。
というか、その議論は見飽きたので触れません。)

データの永続化について

今回はリポジトリ層で、次の3種類のデータソースを適宜キャッシュ・保存しつつ切り替えるといったことをしています。

  • リモート (connpassのAPIを叩く。)
  • ローカル (RoomによってローカルDB、SQLiteで保存する。)
  • オンメモリ

今回はリモートへのWriteは発生しなかったので、少し違う部分もありますが、大まかな部分は Android Architecture Blueprints を参考にしています。

Retrofitについて

connpassのAPIを叩くのには Retrofit を使いました。
関数にパラメータを渡して呼び出すと勝手にクエリに展開してくれて、APIを叩いてくれて、レスポンスをパーサに掛けてくれて、結果をオブジェクトとして取得することができます。

interface ConnpassService {

    @GET("event/")
    fun getEvents(
        @Query("keyword_or") orKeywords: List<String>,
        @Query("start") searchStartingFrom: Int = 1,
        @Query("ym") heldIn: Int? = null,
        @Query("ymd") heldOn: Int? = null,
        @Query("order") orderType: Int = 2,
        @Query("count") count: Int = 100
    ): Single<ConnpassResponse>
}

こんな感じのインターフェースを定義すると、次の呼び出し部のコードを見ての通り Single<ConnpassResponse> として返ってくるような実装を勝手にしてくれます。
(多分 Single<T> として返して欲しいときは com.squareup.retrofit2:adapter-rxjava2 を入れる必要があった気がしますけどね。)

val gson = GsonBuilder()
    .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
    .create()

val retrofit = Retrofit.Builder()
    .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
    .addConverterFactory(GsonConverterFactory.create(gson))
    .baseUrl(this.baseUrl)
    .build()

val connpassService: ConnpassService = retrofit.create(ConnpassService::class.java)

val result: Single<ConnpassResponse> =
    connpassService.getEvents(orKeywords, searchStartingFrom, heldIn, heldOn, orderType, count)

参考: APIリファレンス - connpass

Dagger2について

一応、DIコンテナとして Dagger を導入してみました。
しかし、今回はあんまし、その恩恵をうまく使えていなかった気がします。
(今のコードを見ても「DIコンテナ、いらなくね?」となるような感じかな。)
そこの設計ももう少し勉強しておきたいです。

一応、このコミット bc939f7219 にサンプル的な使い方をしているコードがあります。

また、ViewModelProviderをどうしようか、ということも少し考えました。
DIコンテナに依存関係の解決 (主にユースケースレベルでの注入) をViewModelのコンストラクタでやって貰おうと思いました。
しかし、デフォルトの ViewModelFactory は引数なしコンストラクタが必要です。
そこで、そのFactoryを自分で書いて解決することにしました。

class MainFragment : Fragment() {

    private val viewModel: MainFragmentViewModel by lazy {
        ViewModelProviders.of(this.activity!!, this.viewModelFactory)
            .get(MainFragmentViewModel::class.java)
    }

    ...
}
@Suppress("UNCHECKED_CAST")
class ViewModelFactory private constructor(
    private val application: MyApplication
) : ViewModelProvider.Factory {

    companion object {
        @Volatile
        private var instance: ViewModelFactory? = null

        fun getInstance(application: MyApplication): ViewModelFactory =
            this.instance ?: synchronized(ViewModelFactory::class.java) {
                this.instance ?: ViewModelFactory(application).also { this.instance = it }
            }
    }

    override fun <T : ViewModel?> create(modelClass: Class<T>): T = modelClass.let {
        try {
            when {
                it.isAssignableFrom(MainFragmentViewModel::class.java) -> this.createMainFragmentViewModel()
                else -> modelClass.newInstance()
            }
        } catch (ex: Exception) {
            throw IllegalArgumentException()
        }
    } as T

    private fun createMainFragmentViewModel(): MainFragmentViewModel =
        this.application.viewModelComponent.createMainFragmentViewModel()
}
//  拡張プロパティを生やす。
val Fragment.viewModelFactory: ViewModelFactory
    get() {
        val application = this.activity!!.application as MyApplication
        return ViewModelFactory.getInstance(application)
    }
class MyApplication : Application() {
    //  MyApplicationにDaggerのComponentを保持させる。
    val viewModelComponent: ViewModelComponent by lazy {
        DaggerViewModelComponent.builder()
            .modelModule(ModelModule(this.applicationContext))
            .build()
    }
    ...
}

Factoryのコードは一応 Android Architecture Blueprint のものを参考にしました。
ただ、DaggerのScopeなどを考慮したとき、これでいいのか? という疑問はありますが。
(今回はすべて @Singleton だったのでよかったかもですけど、私もスコープについて詳しく理解していないので、もう少し勉強しておきたいです。)

ThreeTenABPについて

はじめは CalendarDate を使って日時の管理をしようと思ったのですが、ISO-8601 形式の日付文字列のパースがJVM実装とAndroid実装で挙動が異なり、単体テストが通らないとかいう自体に陥ったのと、古いAPIみたいで、ほとんどのメンバがdeprecatedだったので使うのをやめました。
(これで結構な時間を無駄にした気がする。)

そのかわりとして、どうせなら今後は新しい Java8 以降の java.time.OffsetDateTime などの扱いに慣れておいたほうがいいと思ったので、バックポートライブラリの ThreeTenABP を使うことにしました。
旧APIに比べて断然使いやすかったです。

おわりに

というわけで、Androidを入門したてですけども、なんとなく書き方の雰囲気は掴めてきたくらいになったというお話です。
とはいえ、まだまだ分からないことばかりなので、ご指摘があれば Twitter のDMなりメンションなりで教えてくださると嬉しいです。

サブネミミッミ

作成日: 2019/05/21