Now in Android App - 2022/05/25
一言で表すと
Mori Atsushi
テスト周り見ていく
core-testing
repository
固定値を返すmockが書かれている
code:kotlin
class TestNewsRepository : NewsRepository {
private val newsResourcesFlow: MutableSharedFlow<List<NewsResource>> =
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
...
TestDispatcherRule
main dispatcherをStandardTestDispatcherに差し替える
NiaTestRunner
applicationをHiltTestApplicationに差し替えてるっぽい
TestDispatcherModule / TestDispatchersModule
テストでdispatcherを UnconfinedTestDispatcher に差し替えている
Mori Atsushi.icon 結局 StandardTestDispatcher と UnconfinedTestDispatcher のどっちをメインに使えば良いんだろう…?
Go.iconより実際の動作に近いのはStandardTestDispatcherかなと思って、こっち使ってる🙄
メモ:でもviewModelScopeはUnconfinedTestDispatcherに近いはず
core-data-test
TestDataModule
code:kotlin
interface TestDataModule {
@Binds
fun bindsTopicRepository(
fakeTopicsRepository: FakeTopicsRepository
): TopicsRepository
repositoryをFakeRepositoryに差し替えている
fakeは core-data にある(testディレクトリではない)
ネットワーク接続なしに開発できるよ!みたいなコメントが書いてある
実際はテストでのみ使われてそう
テストはappのNavigationTestのみ(integration testっぽい)
NavigationTest
beforeをつかってcontextからstringを取得
code:kotlin
private lateinit var navigateUp: String
@Before
fun setup() {
composeTestRule.activity.apply {
navigateUp = getString(R.string.navigateUp)
nodeの検索等に使える
code:kotlin
@Test
fun topLevelDestinations_doNotShowUpArrow() {
composeTestRule.apply {
onNodeWithContentDescription(navigateUp).assertDoesNotExist()
初期状態でforYouが選択されているテスト
code:kotlin
@Test
fun firstScreen_isForYou() {
composeTestRule.apply {
onNodeWithText(forYou).assertIsSelected()
}
}
ViweModelのTest
turbineを使ってFlowの検証をしている
TestRepositoryを使っている
CIでの動作
macOSでないと動かない
NabeNabe.icon
Navigation 周りを見ていく
遷移周りは Navigation-Compose が使われている
NiaNavigationDestination という route と destination を持つ interface があって、これを各画面で実装している
Nabe.iconちなみに Nia は Now in Android の略
引数は実装側で定義している
code:AuthorNavigation.kt
object AuthorDestination : NiaNavigationDestination {
override val route = "author_route"
override val destination = "author_destination"
const val authorIdArg = "authorId"
}
引数を受け取る時は ViewModel で savedStateHandle 経由で受け取っている
code:AuthorViewModel.kt
private val authorId: String = checkNotNull(
)
chigichan24.icon checkNotNull でとるのかぁ....
mayamito.icon ↑開発時に引数渡し忘れに気づきやすいので自分は肯定派
chigichan24.icon ↑ 人間はコンパイル時に解決したい生き物(?)
mayamito.icon ↑理想の世界;;
遷移するときは NiaNavigationDestination#route をそのまま使用している
code:NiaNavHost.kt
navController.navigate("${AuthorDestination.route}/$it")
Nabe.icon遷移の定義をコード生成にするパターンがよくあるけど、ここでは素朴に文字列を使って実装されている
Navigationの composable は各 feature モジュールに拡張関数として定義されている
code:AuthorNavigation.kt
fun NavGraphBuilder.authorGraph(
onBackClick: () -> Unit
) {
composable(
route = "${AuthorDestination.route}/{${AuthorDestination.authorIdArg}}",
arguments = listOf(
navArgument(AuthorDestination.authorIdArg) {
type = NavType.StringType
}
)
) {
AuthorRoute(onBackClick = onBackClick)
}
}
Nabe.iconfeature のなかに遷移の定義があるのは良さそう
NavHost は NiaNavHost として app モジュールにある
各画面への遷移処理も Stateful な Composable の中ではなく、この Navigation の起点となる場所で行われている
Nabe.iconfeature 間を依存させない作りにできている
Mori Atsushi.icon navigateToTopicみたいなのをひたすら子に渡していくのつらそう
Nabe.iconStateful な Composable を ~Route、Stateless な Composable を ~Screen と命名しているのは参考にできそう
interestsGraph だけ nested になっていて、multi back stack に対応されている
Mori Atsushi.icon navigationはanimationもなんとかしてほしい
chigichan24
core-datastore、core-datastore-test を見てみる
core-datastore
di
Hilt で配られている。
ioDispatcher が渡されている。
@Dispatcher(IO) ioDispatcher: CoroutineDispatcher,
この書き方するのちょっと便利そう。
Mori Atsushi.icon ずっとniaってなんだよって思ってたけどnow in androidか
いまのところ IO しか Dispatchers の種類として定義されていないけども..
IntToStringIdsMigration という migration ファイルが用意されているらしい。
context.dataStoreFile("user_preferences.pb") でスキーマを読み込んでいる。
今風だ
user_preference.proto のパスは、proto/com/google/sample/... においてあって、こういうもんなのかな?
これ、地味に Project モードにしないと見えないので不便。
NiaPreferences.kt
preference への書き込みなど
suspend fun だったり、preference から読みだしたデータが flow で返っていたりと DataStoreの現代的な部分がいい感じに使われている。
Mori Atsushi.icon readはflowしか提供されてなくてfirstとかで取らないといけない。めんどくさくない?
IntToStringIdsMigration.kt
DataStore のマイグレーション用のクラス。
DataMigration<T> が androidx.datastore.core に用意されているの知らなかった。
Mori Atsushi.icon 知らなかった
interface は 以下の3つを持っている。
suspend fun cleanUp()
suspend fun migrate(currentData: T): T
suspend fun shouldMigrate(currendData: T): Boolean
migrationが必要みたいなフラグとかを持っておくと楽に実行できる感じっぽい。
このプロジェクトでもuser_preferences.pb に has_done_int_to_string_id_migration というのがある。
UserPreferencesSerializer.kt
お決まりの実装
user_preferences.pb が読めなかったときの例外とか吐くだけ。
ChangeListVersions.kt
ただの preferences のモデルクラス
test
migration のテストと、serializer が動くかどうかのテストが書いてある。
DataStore は dsl も吐いてくれるっぽい。便利だ。
code:kotlin
val expectedUserPreferences = userPreferences {
followedTopicIds.add("0")
followedTopicIds.add("1")
}
core-datastore-test
@TestInstallIn で、replaces = [DataStoreModule::class] が指定されているので、テストのときには、こっちが呼ばれる。
テスト用の tmpFolder: TemporaryFolder に、user_preferences_test.pb を作ってテスト自体を実行させるっぽい。
org.junit.rules に TemporaryFolder というのがあるのを普通に知らなかった。
TemporaryFolder はテスト後に、ディレクトリを自動削除するのでいい感じになる。
Mori Atsushi.icon これも知らなかった
Go
Theme周りを見ていくぞい
Theme.kt
配色の優先順位は ダイナミックカラー(Android12+)>Androidテーマ>デフォルトテーマ
chigichan24.icon 12+ が SDK version かと一瞬思って???ってなったw
Go.iconAndroidテーマってなんだ。緑色ベースのThemeぽい。
Androidテーマとデフォルトテーマにはそれぞれダークテーマがある
ダイナミックカラーのColorSchemeはdynamicLightColorScheme() or dynamicDarkColorScheme()で取ってきている
LightDefaultColorScheme | dynamicLightColorScheme | LightAndroidColorScheme
https://scrapbox.io/files/628e2291f51b8b0023b2b14f.pnghttps://scrapbox.io/files/628e229e46b6fa00239f0217.pnghttps://scrapbox.io/files/628e27ce2d319b0023ecb713.png
BackgroundThemeを作っている
テーマに応じてbackgroundThemeを設定している
Androidテーマ以外はcolorとtonalElevationを与えている
code:kotlin
darkTheme -> BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp
)
Go.iconやっぱりマテリアルデザイン的には直でカラーを指定するんじゃなくて、elevationで設定していくのがいいのかな🤔
chigichan24.icon theme (AndroidTheme or DarkTheme or dynamicColor) に応じて、gradientColor / color / tonalEleveation を出し分ける実装作ってるの偉い(?)な
Color.kt
Go.icon↓なんだこれ
Mori Atsushi.icon TODO: Link to bugがウケる
code:kotlin
// 現在のColorインスタンスを与えられた輝度まで明るくする。
// これは、トークン値に直接アクセスできないので必要です。
// 動的なカラーテーマの場合、これによって異なるテーマカラーの 95% の輝度トークンを取得することが不可能になる。
// TODO: Link to bug
internal fun Color.lighten(luminance: Float): Color {
val hsl = FloatArray(3)
ColorUtils.RGBToHSL(
(red * 256).roundToInt(),
(green * 256).roundToInt(),
(blue * 256).roundToInt(),
hsl
)
val color = Color(ColorUtils.HSLToColor(hsl))
return color
}
dynamicColor時のxxxGradientColorの指定に使われるぽい
code:kotlin
val backgroundTheme = when {
...
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp,
primaryGradientColor = colorScheme.primary.lighten(0.95f),
secondaryGradientColor = colorScheme.secondary.lighten(0.95f),
tertiaryGradientColor = colorScheme.tertiary.lighten(0.95f),
neutralGradientColor = colorScheme.surface.lighten(0.95f)
)
mainブランチでコミットしたぽくて、プルリクで議論とかない
Nabe.iconDynamic colorの場合は primaryやsecondaryしかアクセスできずに、例えばキーカラーで生成されたカラーパレットの90を使いたいけどアクセスできないからコードで頑張って輝度をあげている感じですね👀
chigichan24.icon カラーパレットから選ぶようにしてたはずなのに、どうしてこんな無理矢理...
mayamito
適当にfeatureを見てく
InterestsViewModel.kt
普通にandroidxのViewModelを使ってる
chigichan24.icon 安心と信頼のAndroidViewModel
@HiltViewModel でDI
状態管理はStateFlowを使っている
LiveDataやComposeのStateの選択肢ももあるけど、StateFlowを使ってるのはなんでだろ
Mori Atsushi.icon LiveData is Deprecated!(炎上)
Nabe.iconwww
combine使ってるのはいいね
Go.icon状態をStateFlowで持っているうま味を感じる
MutableStateFlow<T>.update を使っている
code:kotlin
fun switchTab(newIndex: Int) {
if (newIndex != tabState.value.currentIndex) {
_tabState.update {
it.copy(currentIndex = newIndex)
}
}
}
Flowの更新をatomicにできるらしい
UIStateの型はsealed interfaceにしてる
mayamito.icon 僕は好き
chigichan24.icon 僕もこれ好き 𝑩𝑰𝑮𝑳𝑶𝑽𝑬♡
InterestsScreen.kt
InterestsRoute
これがこの画面のルートComposable
引数の viewModel がデフォルト引数で hiltViewModel() を指定されている
このComposableでviewModelの各種StateFlowをStateに変換したり各種メソッドを値として下位のコンポーネントに渡している
InterestsScreen
InterestsRoute が唯一持つ子のComposable
UIの表示とUIイベントのハンドリングに責務を持つ
気になるポイント
メモ
コメント