android/trackr
日付:2021/09/30
URL:https://github.com/android/trackr
調査者:Mori Atsushi, chigichan24, mayamito
カテゴリ:Sample
Mori Atsushi
shared配下を読む
usecase
全てoperator invokeのみのクラス
suspend functionのものとFlowを返すものがある
daoを持っている
repository patternではない
大体はdaoを読んでるだけであまりロジックは入ってない
code:kotlin
class ArchiveUseCase @Inject constructor(
private val taskDao: TaskDao
) {
suspend operator fun invoke(taskIds: List<Long>) {
taskDao.setIsArchived(taskIds, true)
}
}
db
TaskDaoのみ
Transaction も使って結構ゴリゴリ書かれている
Mori Atsushi.icon データ整合性を考えるとUseCaseではなくDaoに処理が偏ってしまうのは仕方がないのかな
// TODO :consider creating UserDao and moving some of the logic in this Dao there
UserDaoを作りたいみたい
Mori Atsushi.icon migration大変そう
AppDatabaseTypeConverters
TypeConverters
Roomで追加で使用できるtypeを定義できる
code:kotlin
@TypeConverter
fun instantToLong(value: Instant): Long {
return value.toEpochMilli()
}
@TypeConverter
fun longToInstant(value: Long): Instant {
return Instant.ofEpochMilli(value)
}
DatabaseView
クエリをクラスにカプセル化することができる
code:kotlin
@DatabaseView(
"""
SELECT
t.id, t.title, t.description, t.status, t.createdAt, t.dueAt, t.orderInCategory,
t.isArchived,
o.id AS owner_id, o.username AS owner_username, o.avatar AS owner_avatar,
c.id AS creator_id, c.username AS creator_username, c.avatar as creator_avatar
FROM tasks AS t
INNER JOIN users AS o ON o.id = t.ownerId
INNER JOIN users AS c ON c.id = t.creatorId
"""
)
data class TaskDetail(
/* ... */
Entityと同じようにSELECTができる
code:kotlin
@Query("SELECT * FROM TaskDetail WHERE id = :id")
fun findTaskDetailById(id: Long): Flow<TaskDetail?>
INSERT、UPDATE、DELETE は出来ない
Embedded
ネストされたフィールドを直接参照できる
code:kotlin
@Embedded(prefix = "owner_")
val owner: User,
Relation
リレーションエンティティを自動的にフェッチできる
連想テーブルが間にある場合は associateBy を使う
code:kotlin
// Task ← UserTask → User
@Relation(
parentColumn = "id",
entity = User::class,
entityColumn = "id",
associateBy = Junction(
value = UserTask::class,
parentColumn = "taskId",
entityColumn = "userId"
)
)
data
RoomのEntity、DatabaseViewと兼用
Mori Atsushi.icon 簡単なアプリだとありだけど、やっぱりプロダクションでは分けるべきな気がする?
di
AppModule
hiltのSingletonComponent
データベースの初期化とか
code:kotlin
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
appDatabase = Room.databaseBuilder(
context,
AppDatabase::class.java,
"trackr-db"
)
.fallbackToDestructiveMigration()
.addCallback(object : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
GlobalScope.launch(Dispatchers.IO) {
insertSeedData()
}
}
})
.build()
return appDatabase
}
fallbackToDestructiveMigration: migrationに失敗したら全てを破壊する
Mori Atsushi.icon プロダクションでは使わない方がいいかもね
初回のみseed dataを入れている
Mori Atsushi.icon GlobalScope はいい感じのapplication scopeをDIすれば良さそう
chigichan24.icon delicateApi は delicate なのでね
Mori Atsushi.icon Dispatchers.IO を指定してるけど、Roomは勝手にIOスレッドにしてくれるので指定しなくて良いはず
mayamito.icon みんなやりがち
Mori Atsushi.icon こういうの、大きくなりがちなので別のところに切り出したいなと思うんだけど、いつも悩む
Mori Atsushi.icon android studioで利用先や宣言元に飛べるの便利だなと思いました(コナミ)
chigichan24.icon Dagger (dagger-hilt)のメリット
utils
DateTimeUtils
chigichan24
app-compose を読む
MainActivity
Main()を呼び出すだけ
ui/Main.kt
最上位は安定の TrackrTheme でくくっている
NavHost をつかっているandroidx.navigation.compose
code:kotlin
@Composable
public fun NavHost(
navController: NavHostController,
startDestination: String,
modifier: Modifier = Modifier,
route: String? = null,
builder: NavGraphBuilder.() -> Unit
) {
NavHost(
navController,
remember(route, startDestination, builder) {
navController.createGraph(startDestination, route, builder)
},
modifier
)
}
startDestination などを stringで管理するので、管理にミスったときに実行時に落ちそう。
適当なのを指定したら落ちた。
別でページの route のリストを作る必要がありそうで、なんかもう少しいい感じになってほしい。
ページとしては、tasks と detail/{taskId} がある。
hiltViewModelを渡している
適当にtaskIdとかをもたせて渡すことも割と簡単にできるんだね。
Mori Atsushi.icon constructorに渡したくない??
chigichan24.icon 確かに
code:kotlin
// Main.kt
hiltViewModel<TaskDetailViewModel>().apply {
taskId = backStackEntry.arguments?.getLong("taskId") ?: 0L
}
// TaskDetailViewModel.kt
@HiltViewModel
class TaskDetailViewModel @Inject constructor(): ViewModel() {
var taskId: Long = 0L
}
ui/Theme.kt
trackr の colorpalette を作成し、それを配っている
code: kotlin
@Composable
fun TrackrTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
// Allow the content to access the TrackrTheme object.
ProvideTrackrColors(if (darkTheme) DarkTrackrColors else LightTrackrColors) {
MaterialTheme(
colors = if (darkTheme) DarkColorPalette else LightColorPalette,
shapes = Shapes(
medium = RoundedCornerShape(8.dp),
small = RoundedCornerShape(8.dp),
),
content = content
)
}
}
ProvideTrackrColors はTrackrColors.kt にいる
CompositionLocalProviderに持つ感じ
code:kotlin
@Composable
internal fun ProvideTrackrColors(
colors: TrackrColors,
content: @Composable () -> Unit
) {
val colorPalette = remember { colors.copy() }
colorPalette.update(colors)
CompositionLocalProvider(LocalTrackrColors provides colorPalette, content = content)
}
ui/tasks/tasks.kt
ViewModel は最上位のところだけで使ってあとは、パラメータを渡す感じ。
https://scrapbox.io/files/6155b031fdc74a001f29f199.png
TasksとTaskContentの関係
code:kotlin
@Composable
fun Tasks(
viewModel: TasksViewModel,
onTaskClick: (taskId: Long) -> Unit,
onAddTaskClick: () -> Unit,
onArchiveClick: () -> Unit,
onSettingsClick: () -> Unit
) {
val statusGroups by viewModel.statusGroups.collectAsState(emptyMap())
TasksContent(
statusGroups = statusGroups,
clock = viewModel.clock,
onStatusClick = { viewModel.toggleStatusExpanded(it) },
onStarClick = { viewModel.toggleTaskStarState(it) },
onTaskClick = onTaskClick,
onAddTaskClick = onAddTaskClick,
onArchiveClick = onArchiveClick,
onSettingsClick = onSettingsClick
)
}
Mori Atsushi.icon 良さそう
LazyColumnのstickyHeader使っている。
これまだ experimental だった
昔使ったことあるけどいい感じだった
TaskSummaryCardが本体だけどこれをAnimatedVisibility でくるまれている。
これでやると enter と exit ときのアニメーション + visibility をいい感じにしてくれている。
ui/tasks/tasksViewModel.kt
User, Clock, GetOngoingTaskSummariesUseCase, ToggleTaskStarStateUseCase が inject されている
clock は使っていない
itemのstar, statusExpand の toggle 操作に責務を持っている。
ui/detail
TaskDetailはまだそんなに実装されていない。
Mori Atsushi.icon PRを出そう!
viewmodel 経由で taskId が渡せているのを確認しているだけ。
https://scrapbox.io/files/6155b00d79329400233dfa19.png
mayamito
app配下を読む
DIにDagger Hiltを使ってて良き
MainActivity
xmlはNavigationRailViewとFragmentContainerがあるのみ
Fragmentは androidx.navigation で管理
mobile_navigation.xml を参照
タスク一覧、アーカイブ済みタスク一覧、設定、タスク編集への画面遷移がある
TaskTwoPaneFragment
BaseTwoPaneFragment を継承している
SlidingPaneLayoutをいい感じに扱うためのやつ
code:BaseTwoPaneFragment.kt
abstract class BaseTwoPaneFragment : Fragment {
constructor() : super()
constructor(@LayoutRes contentLayoutId: Int) : super(contentLayoutId)
private val twoPaneViewModel: TwoPaneViewModel by activityViewModels()
private lateinit var backPressHandler: SlidingPaneBackPressHandler
/** Retrieve this fragment's SlidingPaneLayout. */
abstract fun getSlidingPaneLayout(): SlidingPaneLayout
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val slidingPaneLayout = getSlidingPaneLayout()
slidingPaneLayout.apply {
lockMode = SlidingPaneLayout.LOCK_MODE_LOCKED
doOnLayout { // Wait for layout so that isSlideable has the correct value.
twoPaneViewModel.isTwoPane.value = !isSlideable
if (!isSlideable) {
doOnApplyWindowInsets { v, insets, padding, _ ->
// Consume horizontal insets, otherwise the children might apply padding
// where they shouldn't, such as the left pane padding its right side.
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.updatePadding(
left = padding.left + systemBars.left,
right = padding.right + systemBars.right
)
WindowInsetsCompat.Builder(insets).setInsets(
WindowInsetsCompat.Type.systemBars(),
Insets.of(0, systemBars.top, 0, systemBars.bottom)
).build()
}
}
}
}
repeatWithViewLifecycle {
launch {
twoPaneViewModel.detailPaneUpEvents.collect {
if (backPressHandler.isEnabled) {
backPressHandler.handleOnBackPressed()
}
}
}
launch {
twoPaneViewModel.editTaskEvents.collect { taskId ->
findNavController().navigate(
R.id.nav_task_edit_graph,
NavTaskEditGraphArgs(taskId).toBundle()
)
}
}
}
backPressHandler = SlidingPaneBackPressHandler(slidingPaneLayout)
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPressHandler)
}
}
Fragmentにこんな拡張関数を生やしてるのを見て便利そうという気持ちに
code:kotlin
inline fun Fragment.repeatWithViewLifecycle(
minState: Lifecycle.State = Lifecycle.State.STARTED,
crossinline block: suspend CoroutineScope.() -> Unit
) {
if (minState == Lifecycle.State.INITIALIZED || minState == Lifecycle.State.DESTROYED) {
throw IllegalArgumentException("minState must be between INITIALIZED and DESTROYED")
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.lifecycle.repeatOnLifecycle(minState) {
block()
}
}
}
Mori Atsushi.icon repeatOnLifecycle を早くstableにしてほしい
TasksViewModel
Composeのほうと共通化はしてないのね
HiltViewModelを使っている
privateなプロパティにChannelを使っている
code:TasksViewModel.kt
private val archivedItemChannel = Channel<ArchivedItem>(capacity = Channel.CONFLATED)
val archivedItem = archivedItemChannel.receiveAsFlow()
private val undoReorderTasksChannel = Channel<UndoReorderTasks>(capacity = Channel.CONFLATED)
val undoReorderTasks = undoReorderTasksChannel.receiveAsFlow()
private var detailTaskId: Long? = null
private val showTaskDetailChannel = Channel<ShowTaskDetailEvent>(capacity = Channel.CONFLATED)
val showTaskDetailEvents = showTaskDetailChannel.receiveAsFlow()
と思ったらStateFlowも普通に使ってる、書き換え中なのかな?
chigichan24.icon そんな気がする
code:TasksViewModel.kt
private val expandedStatesMap = MutableStateFlow(
TaskStatus.values().associateWith { true }
)
val listItems = combine(taskSummaries, expandedStatesMap) { taskSummaries, statesMap ->
ListItemsCreator(taskSummaries, statesMap).execute().also { items ->
if (detailTaskId == null && taskSummaries.isNotEmpty()) {
// Show the first item. This will set the detail pane content without opening it.
val firstItem = items.first { it is ListItem.TypeTask } as ListItem.TypeTask
showTaskDetail(firstItem.taskSummary, isUserSelection = false)
}
}
}.stateIn(viewModelScope, WhileViewSubscribed, emptyList())
気になるポイント
コメント