🥳

Android Jetpack をフル活用して、Qiita の LGTM 数ゼロの記事だけ検索できるアプリを作った

2020/10/28に公開

Android Jetpack をフル活用して、Qiita の LGTM 数ゼロの記事だけを検索できるアプリを作りました。

Qiita0LgtmViewer.gif

作ったきっかけ

日本テレビの「月曜から夜ふかし」が好きでよく見るのですが、その中の「再生回数 100 以下の動画を調査」を見て、めちゃくちゃ面白いなと思いました。
自分は Qiita によく記事をアップしているのですが、LGTM がゼロのまま埋れてしまうことがあり、もったいないなと思っていました。
再生回数 100 以下の動画を見て、埋れているから価値が無いかというとそういうわけでは無いんだなと気づき、Qiita でも似たようなことしたら面白そうだなと思ったのがきっかけです。
また、ついでに Android Jetpack についてキャッチアップしたかったというのもあります。

Android Jetpack とは

以下、公式サイトの引用です。

Jetpack はライブラリ スイートです。デベロッパーは Jetpack を使用することで、おすすめの方法に沿って、ボイラープレート コードを削減し、Android の複数のバージョンとデバイスにわたって一貫して機能するコードを作成できるので、コードの重要な部分に集中できます。

使用したライブラリ・アーキテクチャ

アーキテクチャはアプリ アーキテクチャ ガイドにのっとって MVVM パターンを採用しました。

解説

Android Jetpack が提供する機能に絞って解説します。

データ バインディング ライブラリ

データ バインディング ライブラリとは、プログラムではなく宣言形式を使用して、レイアウト内の UI コンポーネントをアプリのデータソースにバインドできるサポートライブラリです。
まず、以下のようなデータクラスを用意しました。

Article.kt
/**
 * 記事データ
 * JSONのキー名に合わせている
 * @param id 記事の一意なID
 * @param title 記事のタイトル
 * @param likes_count この記事への「LGTM!」の数(Qiitaでのみ有効)
 * @param url 記事のURL
 * @param user Qiita上のユーザを表します。
 */
@Parcelize
data class Article(
    val id: String,
    val title: String,
    val likes_count: Int,
    val url: String,
    val user: User
) : Parcelable

拡張性を考慮して Parcelable 化していますが、今回必須ではありません。
プロパティ名をスネークケースにしたのは、Retrofit で受け取った JSON 形式のレスポンスをパースするためです。

Extensions.kt
/**
 * ImageViewにloadImageメソッドを追加するための拡張関数
 * @param url 画像URL
 */
@BindingAdapter("imageUrl")
fun ImageView.loadImage(url: String) {
    Glide.with(context).load(url).into(this)
}

Glide を使って ImageView に画像を読み込ませるために拡張関数を用意しました。
また、@BindingAdapter("imageUrl")を付与することで、view_article.xmlで用意した拡張関数を呼べるようにしました。
view_article.xmlは以下の通りです。

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

    <data>

        <variable
            name="article"
            type="com.kmdhtsh.qiita0lgtmviewer.entity.Article" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:padding="@dimen/view_article_padding">

        <ImageView
            android:id="@+id/profile_image_view"
            android:layout_width="@dimen/profile_image_view_width"
            android:layout_height="@dimen/profile_image_view_height"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            bind:imageUrl="@{article.user.profile_image_url}"
            tools:background="#f00" />

        <TextView
            android:id="@+id/title_text_view"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="@dimen/title_text_view_margin_start"
            android:ellipsize="end"
            android:maxLines="2"
            android:text="@{article.title}"
            android:textSize="@dimen/title_text_view_text_size"
            app:layout_constraintStart_toEndOf="@id/profile_image_view"
            app:layout_constraintTop_toTopOf="parent"
            tools:text="記事のタイトル記事のタイトル記事のタイトル記事のタイトル記事のタイトル記事のタイトル記事のタイトル" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/user_name_text_view_margin_top"
            android:ellipsize="end"
            android:singleLine="true"
            android:text="@{article.user.name}"
            android:textSize="@dimen/user_name_text_view_text_size"
            app:layout_constraintStart_toStartOf="@id/title_text_view"
            app:layout_constraintTop_toBottomOf="@id/title_text_view"
            tools:text="ユーザの名前ユーザの名前ユーザの名前ユーザの名前ユーザの名前" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

LiveData

LiveData とは監視可能なデータホルダークラスです。通常の監視とは異なり、LiveData はActivityFragmentなどのライフサイクルに考慮した監視が可能です。
以下のようなArticleListViewModelクラスを用意しました。

ArticleListViewModel.kt
/**
 * 記事表示用ViewModel
 */
class ArticleListViewModel @ViewModelInject constructor(
    private val searchRepository: SearchRepository
) :
    ViewModel() {
    // 記事一覧(読み書き用)
    // MutableLiveDataだと受け取った側でも値を操作できてしまうので、読み取り用のLiveDataも用意しておく
    private val _articleList = MutableLiveData<Result<List<Article>>>()
    val articleList: LiveData<Result<List<Article>>> = _articleList

    /**
     * 検索処理
     * @param page ページ番号 (1から100まで)
     * @param perPage 1ページあたりに含まれる要素数 (1から100まで)
     * @param query 検索クエリ
     */
    fun search(page: Int, perPage: Int, query: String) = viewModelScope.launch {
        try {
            Timber.d("search start")
            val response = searchRepository.search(page.toString(), perPage.toString(), query)

            // Responseに失敗しても何かしら返す
            val result = if (response.isSuccessful) {
                response.body()!!
            } else {
                mutableListOf()
            }

            // LGTM数0の記事だけに絞る
            val filteredResult = result.filter {
                it.likes_count == 0
            }
            // viewModelScopeはメインスレッドなので、setValueで値をセットする
            _articleList.value = Result.success(filteredResult)
            Timber.d("search finish")
        } catch (e: Throwable) {
            _articleList.value = Result.failure(e)
        }
    }
}

まず、Repository から取得した値を加工し、_articleList.valueで値を更新します(ちなみにメインスレッド以外で更新する場合はpostValueを使うようにしてください)。
すると、ArticleListFragmentのライフサイクルの状態を見計ってviewModel.articleList.observeにデータが流れます。

ArticleListFragment.kt
viewModel.articleList.observe(viewLifecycleOwner, { result ->
    result.fold(
        {
            articleList.addAll(it)
            articleRecyclerViewAdapter.notifyDataSetChanged()
        },
        {
            Timber.e(it)
        }
    )
})

Hilt

Hilt とは Dagger をベースとして作られた DI ライブラリです。
Dagger よりも簡単に DI を実現できます。
また、Dagger と Hilt は、同じコードベース内で共存できます。

まず、Application クラスを継承したクラスを作成し、@HiltAndroidAppアノテーションを付与します。

MainApplication.kt
// Applicationクラスには@HiltAndroidAppが必要
@HiltAndroidApp
class MainApplication : Application() {
    ...
}

次に、ActivityFragmentなど依存関係を注入したいクラスに@AndroidEntryPointアノテーションを付与します。

MainActivity.kt
// DI対象のFragmentの下にあるActivityにも@AndroidEntryPointが必要
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    ...
}

なお、Fragmentに依存関係を注入する場合、Fragmentの下にあるActivityにも@AndroidEntryPointアノテーションが必要です。

ArticleListFragment.kt
/**
 * 記事一覧表示用Fragment
 * DI対象のFragmentには@AndroidEntryPointを付ける必要がある
 */
@AndroidEntryPoint
class ArticleListFragment : Fragment() {
    ...
}

次に Repository クラスのコンストラクタの引数に@Injectアノテーションを使用して、そのクラスのインスタンス提供方法を Hilt に知らせます。

SearchRepository.kt
/**
 * 検索用Repository
 */
class SearchRepository @Inject constructor(private val searchService: SearchService) {
    ...
}

次に ViewModel クラスのコンストラクタの引数に@ViewModelInjectアノテーションを使用して、そのクラスのインスタンス提供方法を Hilt に知らせます。

ArticleListViewModel.kt
/**
 * 記事表示用ViewModel
 */
class ArticleListViewModel @ViewModelInject constructor(
    private val searchRepository: SearchRepository
) : ViewModel() {
    ...
}

@ViewModelInjectアノテーションは ViewModel クラス限定のアノテーションで、これによって以下のように ViewModel クラスのインスタンスを生成できるようになります。

ArticleListFragment.kt
private val viewModel: ArticleListViewModel by viewModels()

最後に、@Injectアノテーションを付けた箇所などにインスタンスの実体モジュールを提供するための Hilt モジュールを作成します。

ApplicationProvidesModule.kt
/**
 * DI用ProvidesModule
 * @Inject が付いたプロパティや引数に提供する値の実体を定義
 */
@Module
@InstallIn(ApplicationComponent::class)
object ApplicationProvidesModule {

    /**
     * HttpLoggingInterceptorの提供
     */
    @Provides
    fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor {
        // OkHttp側でもTimberを使用する
        val logging = HttpLoggingInterceptor {
            Timber.tag("OkHttp").d(it)
        }
        logging.setLevel(HttpLoggingInterceptor.Level.BASIC)
        return logging
    }

    /**
     * OkHttpClientの提供
     * @param httpLoggingInterceptor
     */
    @Provides
    fun provideOkHttpClient(
        httpLoggingInterceptor: HttpLoggingInterceptor
    ): OkHttpClient {
        return OkHttpClient.Builder()
            .addInterceptor(httpLoggingInterceptor)
            .build()
    }

    /**
     * SearchServiceの提供
     * @param okHttpClient
     */
    @Provides
    fun provideSearchService(okHttpClient: OkHttpClient): SearchService {
        return Retrofit.Builder()
            .baseUrl("https://qiita.com")
            .client(okHttpClient)
            .addConverterFactory(MoshiConverterFactory.create())
            .build()
            .create(SearchService::class.java)
    }

    /**
     * SearchRepositoryの提供
     * @param searchService
     */
    @Provides
    fun provideSearchRepository(searchService: SearchService): SearchRepository {
        return SearchRepository(searchService)
    }
}

@InstallIn(ApplicationComponent::class)アノテーションは、Applicationクラスをインジェクション対象とするという意味です。これにより、すべてのActivityFragmentで使えるようになります。
@Providesアノテーションが提供するインスタンスの実体です。
なお、どうやら@Providesアノテーションを付与した関数のインスタンスに関しては、@Injectアノテーションを付けなくても提供されるようです。

まとめ

Android Jetpack をフル活用して、Qiita の LGTM 数ゼロの記事だけを検索できるアプリを作りました。
Android Jetpack のおかげで、比較的簡単に保守性の高い MVVM パターンのコードを書くことができました。
またアプリ自体はシンプルなので、ライブラリの導入も容易にでき、実際に手を動かすことで各種ライブラリの理解を深めることができました。
実際に、Android Jetpack は便利な機能がたくさんあるので、Android エンジニアの方は絶対覚えた方が良いです。
この経験を実務にも生かしていきたいです。

今後の課題

あくまでライブラリの勉強に重きをおいたので、UI は最小限しか作っていません。
改善点として、読み込み時のプログレスバーの表示・非表示だったり、リストの並び替え機能などがあります。
また、テストコードや CI/CD の環境も整えるとさらに良いかなと思っています。
改善に関しては、もし反響があればやってみようかなと思っています。

ソースコード

hiesiea/Qiita0LgtmViewer

追記

続きで以下の記事を実装しました。

Discussion