Android Jetpack をフル活用して、Qiita の LGTM 数ゼロの記事だけ検索できるアプリを作った
Android Jetpack をフル活用して、Qiita の LGTM 数ゼロの記事だけを検索できるアプリを作りました。
作ったきっかけ
日本テレビの「月曜から夜ふかし」が好きでよく見るのですが、その中の「再生回数 100 以下の動画を調査」を見て、めちゃくちゃ面白いなと思いました。
自分は Qiita によく記事をアップしているのですが、LGTM がゼロのまま埋れてしまうことがあり、もったいないなと思っていました。
再生回数 100 以下の動画を見て、埋れているから価値が無いかというとそういうわけでは無いんだなと気づき、Qiita でも似たようなことしたら面白そうだなと思ったのがきっかけです。
また、ついでに Android Jetpack についてキャッチアップしたかったというのもあります。
Android Jetpack とは
以下、公式サイトの引用です。
Jetpack はライブラリ スイートです。デベロッパーは Jetpack を使用することで、おすすめの方法に沿って、ボイラープレート コードを削減し、Android の複数のバージョンとデバイスにわたって一貫して機能するコードを作成できるので、コードの重要な部分に集中できます。
使用したライブラリ・アーキテクチャ
- Android Jetpack
- Retrofit
- Timber
- Glide
- Custom Tabs
アーキテクチャはアプリ アーキテクチャ ガイドにのっとって MVVM パターンを採用しました。
解説
Android Jetpack が提供する機能に絞って解説します。
データ バインディング ライブラリ
データ バインディング ライブラリとは、プログラムではなく宣言形式を使用して、レイアウト内の UI コンポーネントをアプリのデータソースにバインドできるサポートライブラリです。
まず、以下のようなデータクラスを用意しました。
/**
* 記事データ
* 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 形式のレスポンスをパースするためです。
/**
* 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
は以下の通りです。
<?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 はActivity
やFragment
などのライフサイクルに考慮した監視が可能です。
以下のようなArticleListViewModel
クラスを用意しました。
/**
* 記事表示用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
にデータが流れます。
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
アノテーションを付与します。
// Applicationクラスには@HiltAndroidAppが必要
@HiltAndroidApp
class MainApplication : Application() {
...
}
次に、Activity
やFragment
など依存関係を注入したいクラスに@AndroidEntryPoint
アノテーションを付与します。
// DI対象のFragmentの下にあるActivityにも@AndroidEntryPointが必要
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
...
}
なお、Fragment
に依存関係を注入する場合、Fragment
の下にあるActivity
にも@AndroidEntryPoint
アノテーションが必要です。
/**
* 記事一覧表示用Fragment
* DI対象のFragmentには@AndroidEntryPointを付ける必要がある
*/
@AndroidEntryPoint
class ArticleListFragment : Fragment() {
...
}
次に Repository クラスのコンストラクタの引数に@Inject
アノテーションを使用して、そのクラスのインスタンス提供方法を Hilt に知らせます。
/**
* 検索用Repository
*/
class SearchRepository @Inject constructor(private val searchService: SearchService) {
...
}
次に ViewModel クラスのコンストラクタの引数に@ViewModelInject
アノテーションを使用して、そのクラスのインスタンス提供方法を Hilt に知らせます。
/**
* 記事表示用ViewModel
*/
class ArticleListViewModel @ViewModelInject constructor(
private val searchRepository: SearchRepository
) : ViewModel() {
...
}
@ViewModelInject
アノテーションは ViewModel クラス限定のアノテーションで、これによって以下のように ViewModel クラスのインスタンスを生成できるようになります。
private val viewModel: ArticleListViewModel by viewModels()
最後に、@Inject
アノテーションを付けた箇所などにインスタンスの実体モジュールを提供するための Hilt モジュールを作成します。
/**
* 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
クラスをインジェクション対象とするという意味です。これにより、すべてのActivity
やFragment
で使えるようになります。
@Provides
アノテーションが提供するインスタンスの実体です。
なお、どうやら@Provides
アノテーションを付与した関数のインスタンスに関しては、@Inject
アノテーションを付けなくても提供されるようです。
まとめ
Android Jetpack をフル活用して、Qiita の LGTM 数ゼロの記事だけを検索できるアプリを作りました。
Android Jetpack のおかげで、比較的簡単に保守性の高い MVVM パターンのコードを書くことができました。
またアプリ自体はシンプルなので、ライブラリの導入も容易にでき、実際に手を動かすことで各種ライブラリの理解を深めることができました。
実際に、Android Jetpack は便利な機能がたくさんあるので、Android エンジニアの方は絶対覚えた方が良いです。
この経験を実務にも生かしていきたいです。
今後の課題
あくまでライブラリの勉強に重きをおいたので、UI は最小限しか作っていません。
改善点として、読み込み時のプログレスバーの表示・非表示だったり、リストの並び替え機能などがあります。
また、テストコードや CI/CD の環境も整えるとさらに良いかなと思っています。
改善に関しては、もし反響があればやってみようかなと思っています。
ソースコード
追記
続きで以下の記事を実装しました。
Discussion