Androidアプリを1から作ってみた記録
定期的に自分用に作ってるサンプルプロジェクトの2022年7月開始版のメモです。
スマホのデザインサイトを見ながら画面構成を真似してJetpackComposeで書く修行を目的に用意した物になります。
モジュールは現時点ではこのような形です。
APIモックライブラリ
画面を作る練習が目的とは言えState管理も意識してやっていきたかったので、
Retrofitを使って実務に近い形にしたかったこともあり
こちらのライブラリを使用しました。
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で対応
※業務でもこんな作りにしていたので、悪くはなかったんだとは思えました。
private val retryTrigger = RetryTrigger()
private val listData = retryableFlow(retryTrigger) {
getListDataUseCase().asResult()
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
null
)
ViewModelをこの作りにすることで、retryTrigger.retry()で手動リトライが実現できました。
Renovateでライブラリ自動更新
チェックしたら↓な感じで勝手に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で動く指定になっていることに今気付いた
ついでにテストとかも実行
ktlint,UTの実行と結果をDangerでコメントするようにしています。
さらにFirebase Test Labと連携してインストルメンテーションテストも実行
最後に
頑張った!!
参考にした記事書いてくれたみなさんありがとうございます。
※面倒になったので色々省略したので、気が向いたら深堀できるところは書くかもです。
Discussion