ベータコンピューティングの活動や技術、開発のこだわりなどを紹介するブログです。



先日のFlutterでのサンプルアプリをAndroidネイティブアプリでも作ってみた件

Android担当のd-ariakeです。
先日 弊社もFlutter採用致します! - Beta Computingブログ という記事の中で このような サンプルアプリを作り、紹介したかと思います。

今回はこのサンプルアプリと同じようなアプリをAndroidネイティブでも作ってみたので、設計の比較やポイントをご紹介したいと思います。

作ったアプリ

仕様は先日のものとほとんど変わっていません。

https://github.com/BetaComputing/SimpleQiitaClientAndroid

image

この同じような仕様のアプリをネイティブで作った際のポイントを軽く解説していきます。

ポイント

以下が今回説明するポイントとなります。

  • UIの設計
  • ネット上の画像のロード
  • FlexboxLayoutの導入

UIの設計

Flutter版では BLoC パターンで設計していましたが、Androidネイティブ版では MVVM パターンで設計を行いました。

UIはxmlで記述し、ViewModelにUIを制御するための変更通知機構を持ったプロパティ (LiveData) をもたせ、データバインディングによってViewModelのプロパティの状態をUIに反映させています。

internal class MainViewModel(/* 省略 */) : ViewModel() {

    //  取得中かどうか
    private val isFetching: MutableLiveData<Boolean> = MutableLiveData(false)

    //  記事リスト
    private val _articleList = MutableLiveData<List<Article>>()
    val articleList: LiveData<List<Article>> = this._articleList

    //  検索キーワード
    val keyword: MutableLiveData<String> = MutableLiveData("")

    //  検索ボタンが有効かどうか
    val isSearchButtonEnabled: LiveData<Boolean> = MediatorLiveData<Boolean>().also {
        val onChanged = { it.value = this.isFetching.value == false && !this.keyword.value.isNullOrEmpty() }
        it.addSource(this.isFetching) { onChanged() }
        it.addSource(this.keyword) { onChanged() }
    }

    //  プログレスバーを表示するかどうか
    val isProgressBarVisible: LiveData<Boolean> = this.isFetching

    //  検索ボタンがクリックされたとき。
    fun onSearchButtonClicked() { /* 省略 */ }

    //  検索を行う。
    private suspend fun search(keyword: String) { /* 省略 */ }
}
<layout <!-- 省略 --> >

    <data> <!-- 省略 --> </data>

    <LinearLayout <!-- 省略 --> >

        <com.google.android.material.appbar.AppBarLayout />

        <LinearLayout <!-- 省略 --> >

            <androidx.constraintlayout.widget.ConstraintLayout <!-- 省略 -->>

                <com.google.android.material.textfield.TextInputEditText
                    android:id="@+id/keywordEditText"
                    <!-- 省略 -->
                    android:text="@={viewModel.keyword}" />

                <com.google.android.material.button.MaterialButton
                    android:id="@+id/searchButton"
                    <!-- 省略 -->
                    android:enabled="@{viewModel.isSearchButtonEnabled}"
                    android:onClick="@{() -> viewModel.onSearchButtonClicked()}" />

            </androidx.constraintlayout.widget.ConstraintLayout>

            <ProgressBar
                style="@style/Widget.AppCompat.ProgressBar.Horizontal"
                <!-- 省略 -->
                android:visibility="@{viewModel.isProgressBarVisible ? View.VISIBLE : View.INVISIBLE}" />

            <androidx.recyclerview.widget.RecyclerView <!-- 省略 --> />

        </LinearLayout>

    </LinearLayout>

</layout>

例えば、検索ボタンの活性の制御の場合です。
以下のコードで、取得処理中かどうかを表すLiveDataと検索キーワードを表すLiveDataから、検索ボタンの活性に変換しています。

val isSearchButtonEnabled: LiveData<Boolean> = MediatorLiveData<Boolean>().also {
    val onChanged = { it.value = this.isFetching.value == false && !this.keyword.value.isNullOrEmpty() }
    it.addSource(this.isFetching) { onChanged() }
    it.addSource(this.keyword) { onChanged() }
}

そして、xmlのデータバインディング構文を使ってボタンの android:enabled プロパティに反映させています。

<com.google.android.material.button.MaterialButton
    android:id="@+id/searchButton"
    android:enabled="@{viewModel.isSearchButtonEnabled}" />

Flutter版では RxDartcombineLatest2() で変換した Stream/SinkStreamBuilder で実装していましたが、本質的には同じことをやっています。
このように主流の設計パターンが両者とも似ているので、Androidアプリ開発者がFlutterを学習するのはとても敷居が低いと感じました。

ネット上の画像のロード

Flutterではネット上の画像を表示させたいときは、単に

Image.network('URL')

というように書けばOKでした。
Androidネイティブには標準でそのような機能が用意されていので、Glide を使用しました。

Glide.with(context).load("画像のURL").into(imageView)

Glideを使うとこのように書くだけで、通信を行い、画像を反映させ、自動でキャッシュまで行ってくれます。
今回はデータバインディングを使った設計をしていたので、

@BindingAdapter("imageUrl")
fun ImageView.setImageUrl(url: String?) {
    Glide.with(this.context).load(url).into(this)
}

このような BindingAdapter を書いて、

<androidx.appcompat.widget.AppCompatImageView
    app:imageUrl="@{article.authorIconUrl}" />

xml上で画像の指定をできるようにしてみました。

このような処理はよく書くので、Flutterが公式で用意してくれているのはとてもありがたいですね。

FlexboxLayoutの導入

記事のタグを表示している部分は、動的に要素数を変えたり、横幅からはみ出た際に折り返しをしなくてはなりません。

Flutterだと Wrap を使うことで自動的に折り返しを設定してくれます。
Androidネイティブだとそのような機能が標準ではありません。

そこで FlexboxLayout というライブラリを使うことにしました。
使い方はとってもかんたんで <FlexboxLayout /> の下に <RecyclerView /> を入れてやり、FlexboxLayoutManager をセットしてやるだけです。

RecyclerView recyclerView = (RecyclerView) context.findViewById(R.id.recyclerview);
FlexboxLayoutManager layoutManager = new FlexboxLayoutManager(context);
layoutManager.setFlexDirection(FlexDirection.COLUMN);
layoutManager.setJustifyContent(JustifyContent.FLEX_END);
recyclerView.setLayoutManager(layoutManager);

引用元: https://github.com/google/flexbox-layout#flexboxlayoutmanager-within-recyclerview

しかし、Androidネイティブのリスト (RecyclerView) を扱おうと思うと、Adapterに関する部分のコードを書く必要があり、多少めんどくさいです。
そこで、コードの見通しを良くするために、カスタムViewを作ることにしました。

internal class TagList(/* 省略 */) : FlexboxLayout(/* 省略 */), TagClickedListener {
    internal var listener: TagClickedListener? = null
    private val adapter = Adapter(this)

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

        val layoutManager = FlexboxLayoutManager(this.context, FlexDirection.ROW)
        this.recyclerView.layoutManager = layoutManager
        this.recyclerView.adapter = this.adapter
    }

    internal fun setTags(tags: List<String>) { /* 省略 */ }
    override fun onTagClicked(tag: String) { /* 省略 */ }

    private inner class Adapter(private val parent: TagList = this) : RecyclerView.Adapter<BindingHolder>() {
        private val inflater: LayoutInflater by lazy { LayoutInflater.from(context) }
        var tags: List<String> = emptyList()

        override fun getItemCount(): Int = this.tags.size
        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder =
            BindingHolder(TagBinding.inflate(this.inflater, parent, false))
        override fun onBindViewHolder(holder: BindingHolder, position: Int) {
            holder.binding.tag = this.tags[position]
            holder.binding.listener = this.parent
            holder.binding.executePendingBindings()
        }
    }

    private class BindingHolder(val binding: TagBinding) : RecyclerView.ViewHolder(binding.root)
}

@BindingAdapter("tags")
internal fun TagList.setTags(tags: List<String>?) {
    if (tags != null) this.setTags(tags)
}

@BindingAdapter("onTagClicked")
internal fun TagList.setListener(listener: TagClickedListener?) {
    this.listener = listener
}

TagList というカスタムViewとそのBindingAdapterを生やし、

<TagList
    <!-- 省略 -->
    app:onTagClicked="@{tagClickedListener}"
    app:tags="@{article.tags}" />

このようにxml中で使えるようにしてみました。

Flutterだと数行で実現できたことですが、Androidネイティブでやろうとすると、ものすごく大変でした。

感想

UIに関して、シンプルなUIを組むことに限って言えば、Flutterだとものすごく簡単に感じました。
Androidネイティブの場合、ActivityとFragmentの概念や、ライフサイクルの理解と画面の再生成の対応など、知っておかなくてはならないことがたくさんあります。
Flutterだとそれらを意識せずに作れてしまうので、アプリ開発初学者に対する敷居もかなり低くなっていると思います。

当然、Android固有の機能であったり、一部のUIに関してはAndroidネイティブが必要ですし、ネイティブがすぐに不要になるとは思いませんが、今後はFlutterの選択肢も増えていくと思います。