🐥

Androidアプリを1から作ってみた記録

2022/08/26に公開

定期的に自分用に作ってるサンプルプロジェクトの2022年7月開始版のメモです。
スマホのデザインサイトを見ながら画面構成を真似してJetpackComposeで書く修行を目的に用意した物になります。
https://github.com/sobaya-0141/sample202207

モジュールは現時点ではこのような形です。

APIモックライブラリ

画面を作る練習が目的とは言えState管理も意識してやっていきたかったので、
Retrofitを使って実務に近い形にしたかったこともあり
https://github.com/infinum/Retromock
こちらのライブラリを使用しました。

interface ListService {
    @Mock
    @MockResponse(body = "{\"location\":\"421 Lewis Ave,aaaaaaaaaaaa\",\"chips\":[{\"id\":0,\"title\":\"TITLE0\"},{\"id\":1,\"title\":\"TITLE1\"},{\"id\":2,\"title\":\"TITLE2\"},{\"id\":3,\"title\":\"TITLE3\"},{\"id\":4,\"title\":\"TITLE4\"},{\"id\":5,\"title\":\"TITLE5\"},{\"id\":6,\"title\":\"TITLE6\"},{\"id\":7,\"title\":\"TITLE7\"},{\"id\":8,\"title\":\"TITLE8\"},{\"id\":9,\"title\":\"TITLE9\"},{\"id\":10,\"title\":\"TITLE10\"}],\"campaign\":{\"title\":\"Unlimited \$0 delivery fees\",\"target\":\"\$0 delivery\"},\"populars\":[{\"imageUrl\":\"https://github.com/fanzeyi/pokemon.json/blob/master/images/000.png\",\"stars\":\"4.8 (2.9k ratings)\",\"title\":\"TITLE0\",\"time\":\"25-35 mins\"},{\"imageUrl\":\"https://github.com/fanzeyi/pokemon.json/blob/master/images/001.png\",\"stars\":\"4.8 (2.9k ratings)\",\"title\":\"TITLE1\",\"time\":\"25-35 mins\"},{\"imageUrl\":\"https://github.com/fanzeyi/pokemon.json/blob/master/images/002.png\",\"stars\":\"4.8 (2.9k ratings)\",\"title\":\"TITLE2\",\"time\":\"25-35 mins\"},{\"imageUrl\":\"https://github.com/fanzeyi/pokemon.json/blob/master/images/003.png\",\"stars\":\"4.8 (2.9k ratings)\",\"title\":\"TITLE3\",\"time\":\"25-35 mins\"},{\"imageUrl\":\"https://github.com/fanzeyi/pokemon.json/blob/master/images/004.png\",\"stars\":\"4.8 (2.9k ratings)\",\"title\":\"TITLE4\",\"time\":\"25-35 mins\"},{\"imageUrl\":\"https://github.com/fanzeyi/pokemon.json/blob/master/images/005.png\",\"stars\":\"4.8 (2.9k ratings)\",\"title\":\"TITLE5\",\"time\":\"25-35 mins\"},{\"imageUrl\":\"https://github.com/fanzeyi/pokemon.json/blob/master/images/006.png\",\"stars\":\"4.8 (2.9k ratings)\",\"title\":\"TITLE6\",\"time\":\"25-35 mins\"},{\"imageUrl\":\"https://github.com/fanzeyi/pokemon.json/blob/master/images/007.png\",\"stars\":\"4.8 (2.9k ratings)\",\"title\":\"TITLE7\",\"time\":\"25-35 mins\"},{\"imageUrl\":\"https://github.com/fanzeyi/pokemon.json/blob/master/images/008.png\",\"stars\":\"4.8 (2.9k ratings)\",\"title\":\"TITLE8\",\"time\":\"25-35 mins\"},{\"imageUrl\":\"https://github.com/fanzeyi/pokemon.json/blob/master/images/009.png\",\"stars\":\"4.8 (2.9k ratings)\",\"title\":\"TITLE9\",\"time\":\"25-35 mins\"}],\"perks\":[{\"imageUrl\":\"https://github.com/fanzeyi/pokemon.json/blob/master/images/000.png\",\"stars\":\"4.8 (2.9k ratings)\",\"title\":\"TITLE0\",\"time\":\"25-35 mins\"},{\"imageUrl\":\"https://github.com/fanzeyi/pokemon.json/blob/master/images/001.png\",\"stars\":\"4.8 (2.9k ratings)\",\"title\":\"TITLE1\",\"time\":\"25-35 mins\"},{\"imageUrl\":\"https://github.com/fanzeyi/pokemon.json/blob/master/images/002.png\",\"stars\":\"4.8 (2.9k ratings)\",\"title\":\"TITLE2\",\"time\":\"25-35 mins\"},{\"imageUrl\":\"https://github.com/fanzeyi/pokemon.json/blob/master/images/003.png\",\"stars\":\"4.8 (2.9k ratings)\",\"title\":\"TITLE3\",\"time\":\"25-35 mins\"},{\"imageUrl\":\"https://github.com/fanzeyi/pokemon.json/blob/master/images/004.png\",\"stars\":\"4.8 (2.9k ratings)\",\"title\":\"TITLE4\",\"time\":\"25-35 mins\"},{\"imageUrl\":\"https://github.com/fanzeyi/pokemon.json/blob/master/images/005.png\",\"stars\":\"4.8 (2.9k ratings)\",\"title\":\"TITLE5\",\"time\":\"25-35 mins\"},{\"imageUrl\":\"https://github.com/fanzeyi/pokemon.json/blob/master/images/006.png\",\"stars\":\"4.8 (2.9k ratings)\",\"title\":\"TITLE6\",\"time\":\"25-35 mins\"},{\"imageUrl\":\"https://github.com/fanzeyi/pokemon.json/blob/master/images/007.png\",\"stars\":\"4.8 (2.9k ratings)\",\"title\":\"TITLE7\",\"time\":\"25-35 mins\"},{\"imageUrl\":\"https://github.com/fanzeyi/pokemon.json/blob/master/images/008.png\",\"stars\":\"4.8 (2.9k ratings)\",\"title\":\"TITLE8\",\"time\":\"25-35 mins\"},{\"imageUrl\":\"https://github.com/fanzeyi/pokemon.json/blob/master/images/009.png\",\"stars\":\"4.8 (2.9k ratings)\",\"title\":\"TITLE9\",\"time\":\"25-35 mins\"}]}")
    @GET("localhost/list")
    suspend fun getListScreenData(): Response<ListScreenReponse>
}

このようにMockアノテーションに返却するJsonを指定できるので、
手軽に使う事ができます。
※エラー発生率などを指定できるretrofit-mockの方が検証目的には向いてると思います。

UseCaseを5万回作り直した

今の作り

class GetListDataUseCaseImpl @Inject constructor(
    private val listRepository: ListRepository
) : GetListDataUseCase {
    override fun invoke(): Flow<ListScreenReponse> =
        listRepository.getListScreenData()
}

現時点ではこのようにFlowを返却する作りにしました。
が、Flowを返却する作りの場合だと画面にリトライボタンを置いて手動でリトライする時に不便なので悩んでいます。

悩んでボツにした実装

class GetListDataUseCaseImpl @Inject constructor(
    private val listRepository: ListRepository
) : GetListDataUseCase {
    private val _listData = MutableSharedFlow<ListScreenReponse?>()
    override val listData: SharedFlow<ListScreenReponse?> = _listData

    override suspend operator fun invoke() {
        val data = listRepository.getListScreenData()
        _listData.emit(data)
    }
}

invokeをsuspend funにしてAPIリクエストを投げる(Repositoryを呼ぶ)だけ、
その結果は公開しているSharedFlowにemitし、
公開しているSharedFlowをViewModelが購読する作りにしました。

これならViewModelはこのUseCaseを呼べば何回でも最新のデータを取得可能だし、
結果もFlowで受け取れてその後のState管理も楽ちんと思っていました。

ボツにした理由

通信周りで例外時どうするか問題発生

val response = listService.getListScreenData()
if (response.isSuccessful) {
	emit(response.body()!!)
} else {
	throw SampleNetworkException(
	    statusCode = response.code(),
	    errorMessage = response.message()
	)
}

そもそも通信エラー時はRepositoryで例外throwしてるじゃん・・・となりました。

例外が出ることの何が問題かと言うとViewModelがこんな作りになっています。

private val listData: Flow<Result<ListScreenReponse>> = getListDataUseCase().asResult()

private fun observeApi() {
    listData.onEach {
        uiState = when (it) {
            is Result.Success -> {
                uiState.copy(isLoading = false, data = it.data)
            }
            is Result.Loading -> {
                uiState.copy(isLoading = true, isError = false, data = null)
            }
            is Result.Error -> {
                uiState.copy(isLoading = false, isError = true, data = null)
            }
            else -> {
                throw IllegalStateException()
            }
        }
    }.launchIn(viewModelScope)
}

Now in Androidと同じ形で受け取ったFlowに対してasResultでcatchやらなんやらして、
一括で結果の把握ができるようにしています。

でも、SharedFlow形式だとthrowするのはsuspendなinvokeメソッドです。
と、なると受け取ったFlowに対してasResultしてLoad中か成功かの判定を行い、
別途UseCaseの呼び出し時にtry〜catchしつつ、uiStateを更新する必要が出てきます。

なんとなくオシャレに感じないのでやめました。
※asResultを使うことだけを守れば例外が出ても開発者は意識しなくて良いところを守りたかった。

ViewModelで対応

https://betterprogramming.pub/how-to-manually-retry-a-call-using-kotlin-flow-85a801d3557d
こちらを見てやっぱり別のFlowをトリガーにするしかないよなと思い、この作りにしました。
※業務でもこんな作りにしていたので、悪くはなかったんだとは思えました。

    private val retryTrigger = RetryTrigger()
    private val listData = retryableFlow(retryTrigger) {
        getListDataUseCase().asResult()
    }.stateIn(
        viewModelScope,
        SharingStarted.Eagerly,
        null
    )

ViewModelをこの作りにすることで、retryTrigger.retry()で手動リトライが実現できました。

Renovateでライブラリ自動更新

https://www.mend.io/free-developer-tools/renovate/
Repositoryを指定してあげるとチェックしていいか確認のISSUEが立って、
チェックしたら↓な感じで勝手にPRが飛んできます。

初めてのGithubActions

RenovateでPR出してくれるのは嬉しいものの、誰も確認していないのでそのままマージは厳しい
※実はやってるけど

GithubActionsだとpublic repositoryなら無料!
無料より強い言葉はこの世に存在しないので、飛びつきました。

Project/.github/workflows/deploy.yml

name: deploy

on:
  push:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: set up JDK 11
        uses: actions/setup-java@v3
        with:
          distribution: 'zulu'
          java-version: '11'
      - name: Debug build
        run: ./gradlew assembleDebug
      - name: Deploy Firebase App Distribution [DEV]
        uses: wzieba/Firebase-Distribution-Github-Action@v1.3.4
        with:
          appId: ${{secrets.FIREBASE_DEV_APP_ID}}
          token: ${{secrets.FIREBASE_TOKEN}}
          groups: all
          file: app/build/outputs/apk/debug/app-debug.apk

ネットかGithubの情報を参考にPRが出たらFirebase App Distributionで配信されるようにしました。
※なぜかpushで動く指定になっていることに今気付いた

ついでにテストとかも実行

https://github.com/sobaya-0141/sample202207/blob/main/.github/workflows/ci.yml

ktlint,UTの実行と結果をDangerでコメントするようにしています。

さらにFirebase Test Labと連携してインストルメンテーションテストも実行

最後に

頑張った!!
参考にした記事書いてくれたみなさんありがとうございます。
※面倒になったので色々省略したので、気が向いたら深堀できるところは書くかもです。

Discussion