收藏页 Komikku 对齐迁移 — 阶段交接(2026-09-01)
目的:在任何机器上从当前进度继续实施 计划:
favourites-komikku-alignment-implementation-plan-2026-09.md基线:favourites-komikku-phase0-baseline-2026-09.md分支:devel(提交见文末 git log)
1. 当前进度总览
| Phase | 状态 | 交付物 |
|---|---|---|
| 0 基线/语义清单 | ✅ 完成 | 字段契约 + SQL/聚合链 characterization(46 用例)+ 6.5k/10k 基准 + 基线文档 |
| 1 窄 Room read projections | ✅ 完成 | FavouriteLibraryReadDao(6 个 Flow)+ 窄 row 类型;语义 10 用例 + 规模 2 用例 |
| 2 快照深模块 | ✅ 完成 | FavouriteLibrarySnapshotStore + FavouriteLibrarySnapshot/FavouriteCardRow 等;9+1 用例 |
| 3 纯内存派生 | ✅ 完成 | FavouriteLibraryDeriver(visibility/quickFilters/groupAndSort 三阶段);16 用例 |
| 4 Container 接管状态 | ✅ 完成(管线侧) | libraryState StateFlow + buildFavouriteLibraryUiState 纯函数 + debug shadow comparison 挂点;6 用例 |
| 5 UI 切静态 List | ✅ 完成(渲染侧) | FavouritesCardMapper + FavouriteContentResolver;pagingContent = null;详见 §4 |
| 6 actions 迁移/删逐分类 VM | ✅ 完成(设备回归已补) | ContentListHost seam + FavouritesListHost 适配器;FavouritesListViewModel/Factory 删除;quick filter chips 与 metadata authority 一并转内存/快照;详见 §4.5 |
| 7 删除收藏 Paging 代码 | ✅ 完成(设备回归已补) | FavouriteLibraryPagingRow/WorkFavouritesDao.pagingSource/createFavouritePagingSource/observeFavouriteAggregates/FavouriteLibraryPagingConfig 与两个旧链路测试套删除;findList 改成直接投影 entity;详见 §4.6 |
| 8 验收收敛 | ⬜ 未开始 |
性能实测(Xiaomi M332BF / Android 16 / debug 变体 / Room in-memory):
| 指标 | 旧链路 | 新链路 | 预算 |
|---|---|---|---|
| 6.5k 宽行全量读取 | 360 ms | — | — |
| 10k 窄读取(6 Flow) | — | 138 ms | ≤ 500 ms |
| 10k 完整快照构建(读+组装) | — | 202 ms | ≤ 500 ms |
| 10k 内存派生(filter+sort) | — | < 150 ms(JVM 断言) | ≤ 150 ms |
2. 新代码地图(全部为新增,旧链路未动)
app/src/main/kotlin/org/skepsun/kototoro/
favourites/data/
FavouriteCardBaseRow.kt # 主查询窄 row(~30 字段,全部有消费者)
FavouriteLibraryReadRows.kt # Membership/ProjectionFacet/TagFacet/Downloaded/LegacyOverride rows
FavouriteLibraryReadDao.kt # 6 个 observe* Flow;只读;无过滤参数
favourites/domain/library/
FavouriteLibrarySnapshot.kt # FavouriteCardRow / FavouriteMembership / Snapshot / QuickFilterMetadata
FavouriteLibrarySnapshotStore.kt # @Singleton;observe() 无参 → 完整快照;combine 6 Flow
FavouriteLibraryDeriver.kt # 纯函数三阶段 + FavouriteLibraryDerivationInput/DerivedState
favourites/ui/container/
FavouriteLibraryUiState.kt # UiState + FavouriteLibraryParams + buildFavouriteLibraryUiState 纯函数
core/db/MangaDatabase.kt # + getFavouriteLibraryReadDao()(修改)
core/model/GlobalTagBlacklist.kt # + containsTagTitle(title)(修改)
favourites/ui/container/FavouritesContainerViewModel.kt # + libraryState/startLibraryShadowComparison(修改)
# Phase 5(渲染切换,2026-09-02)
favourites/domain/library/
FavouritesCardMapper.kt # row → ContentListModel(@Reusable,纯核心 buildFavouriteCardModel)
FavouriteContentResolver.kt # 选中项按 displayMangaId 批量解析真实 projection
favourites/ui/compose/FavoritesListScreen.kt # 静态渲染、onResolveSelectionContents
favourites/ui/compose/FavoritesHostScreen.kt # 每页注入 viewModel.listHost(categoryId)
list/ui/compose/AppContentListRoute.kt # + onResolveSelectionContents(分享/下载/分类/override)
list/domain/ContentListMapper.kt # + tagTint(title)
core/prefs/... / res/values/strings.xml # + favourites_broken_projection_title
favourites/domain/library/FavouriteLibraryDeriver.kt # + pinnedIdsByCategory(membership 语义)
favourites/ui/container/FavouriteLibraryUiState.kt # + pinnedIdsByCategory
# Phase 6(唯一状态持有者,2026-09-02)
list/ui/ContentListHost.kt # 路由所需的 state-holder 契约(ContentListViewModel 实现它)
favourites/ui/list/FavouritesListHost.kt # 逐分类适配器(非 VM):切片 + 动作转调 Container
favourites/domain/library/FavouritesQuickFilterOptions.kt # buildFavouritesFilterOptions 纯函数(chips 无 DAO)
favourites/ui/list/FavouritesListViewModel.kt # 删除(连同 @AssistedInject Factory)
# Phase 7(删收藏 Paging,2026-09-02)
favourites/data/FavouriteLibraryPagingRow.kt # 删除(宽行随收藏 Paging 退休)
favourites/data/WorkFavouritesDao.kt # 删 pagingSource / 全量收集包装 / findQuickFilter*;findList 改直接投影 entity
work/domain/WorkAggregateRepository.kt # 删 createFavouritePagingSource / observeFavouriteAggregates / buildFavouritePagingAggregates
core/paging/BatchMappingPagingSource.kt # 删 FavouriteLibraryPagingConfig(历史页仍用 LargeLibraryPagingConfig + BatchMappingPagingSource)
# 测试删除:FavouriteLibrarySemanticsCharacterizationTest(24)、FavouriteLibraryBaselineBenchmarkTest(3)测试:
app/src/test/kotlin/org/skepsun/kototoro/favourites/
ui/FavouriteCardFieldContractTest.kt # JVM,10 用例
domain/library/FavouriteLibraryDeriverTest.kt # JVM,18 用例(Phase 5 +pinned 2)
ui/container/FavouriteLibraryUiStateTest.kt # JVM,7 用例(Phase 5 +pinned 1)
domain/library/FavouritesCardMapperTest.kt # JVM,11 用例(Phase 5 卡片映射契约)
app/src/androidTest/kotlin/org/skepsun/kototoro/favourites/
data/FavouriteLibrarySeed.kt # 共享种子助手(raw SQL)
data/FavouriteLibraryReadDaoTest.kt # 10 用例
data/FavouriteLibraryReadDaoScaleTest.kt # 2 用例(10k)
domain/FavouriteLibraryAggregateChainCharacterizationTest.kt # 12 用例(旧聚合链语义)
domain/library/FavouriteLibrarySnapshotStoreTest.kt # 9 用例
domain/library/FavouriteLibrarySnapshotStoreScaleTest.kt# 1 用例(10k=202ms)3. 必须保留的语义(已 characterization 固化)
2026-09-02(Phase 7):pin 这些语义的旧 SQL characterization 套已随收藏 Paging 一起删除,现在由新链路的
FavouriteLibraryReadDaoTest/FavouriteLibrarySnapshotStoreTest/FavouriteLibraryDeriverTest+ 改写后findList的WorkPagingDaoTest用例固化,对应关系见 §4.6。
- isPinned 恒定所有排序之首;
entity_id恒定 tie-breaker。 - 代表 membership 选择:
pinned DESC, created_at DESC, updated_at DESC, category_id ASC(DAO 用 NOT EXISTS 反连接实现——Room SQL 解析器不支持ROW_NUMBER() OVER,别改回窗口函数)。 - 投影集合 binding-based(anchor 不膨胀
projectionCount/localMangaIds)——MULTI_PROJECTION语义。旧窄聚合路径曾把 anchor 并入(语义分叉),新实现统一为 binding-based(与WorkPagingDaoTest一致)。 - display =
COALESCE(preferred_local_manga_id, anchor);dangling → broken row 保留(entity organize 可达),title 空 → ALPHABETIC 排最后(新实现有意改掉旧 SQL 的 NULL-first)。 - Override 优先级:entity prefs > legacy
preferences(per-manga)(同ContentDataRepository.getOverridesForWorkItems)。 - Tag 过滤 ID =
"${key}_${sourceName}".longHashCode()(ContentTag.toEntity())。 - tracks 聚合 =
SUM(chapters_new)/MAX(last_chapter_date);tracks.owner_id与tracks.manga_id均 UNIQUE(一个 entity 多 track 必须挂不同 manga)。 - groupTab 是 OR 语义(persisted contentType 匹配 或 sourceGroupFlags 匹配即通过)。
- 软删除分类下的 membership 仍可见(与
repairActiveDanglingCategoryRefs维护路径一致)。 - readingStatus:entity prefs 显式 > history percent(≥0.999=COMPLETED,>0=READING,null=PLANNED)。
4. Phase 5/6/7 已完成:UI 切静态 List + 唯一状态持有者 + 删收藏 Paging(2026-09-02)
目标达成:收藏渲染路径不再产生 PagingData、WorkAggregate 或收藏 DAO 读取;每页卡片由 Container 的共享快照在内存里映射。
4.1 渲染链
Container.libraryState (StateFlow<FavouriteLibraryUiState>)
├─ FavoritesHostScreen:activeFavouritesHostRef(顶栏 filter panel 指向当前分类的 host)
└─ KototoroFavoritesListScreen(categoryId, listHost)
└─ Container.listHost(categoryId) → FavouritesListHost(缓存的适配器,不是 VM)
content = combine(libraryState, container.observeListModeWithTriggers())
.flowOn(Default) → FavouritesCardMapper.map(rows, Slice(mode, pinnedIds))
└─ AppContentListRoute(viewModel: ContentListHost;pagingContent == null → 静态 items 分支)FavouritesCardMapper(favourites/domain/library/):FavouriteCardRow→ GRID/COMPACT_GRID(ContentGridModel)/LIST(ContentCompactListModel)/DETAILED_LIST(ContentDetailedListModel)。纯核心是buildFavouriteCardModel(FavouriteCardModelRequest)(无 Android/无 I/O,FavouritesCardMapperTest11 用例覆盖)。字段规则:id = entityId、counter = newChapters(reading 完成时 0)、progress来自 work history(无 history → null)、projectionCountbinding-based、isSaved = isDownloaded、isFavorite = false、isPinned用 membership 标志、override = ContentOverride(overrideCoverUrl, overrideTitle, null);subtitle:grid=altTitle、detailed=altTitle · 后缀、compact=tags.join(", ") · 后缀(后缀=favourites_entity_current_projection[_with_count],与旧groupSuffix等价)。- stub
Content:title/altTitle/cover/author/state/tags/source 全部来自 row,chapters=null、description=null、sourceData=null、url=publicUrl="";contentRating显式置 ADULT/SAFE,使 NSFW badge 与 row 的持久化标志(也是 NSFW 快筛依据)一致。 - broken row 不再消失:
displayMangaId == null的 row 以R.string.favourites_broken_projection_title为标题显示(旧链路在 map 阶段return@mapNotNull null直接丢弃),点击仍走 entity details → entity organize 可达。 - per-slice pinned:
FavouriteLibraryDerivedState.pinnedIdsByCategory/FavouriteLibraryUiState.pinnedIdsByCategory(membership 的 pinned 才是卡片标志;All 切片用代表 membership)。deriver 2 用例 + UiState 1 用例。
4.2 卡片 stub 与真实 projection 的边界
AppContentListRoute 新增可选参数 onResolveSelectionContents: suspend (Set<Long>) -> List<Content>;收藏页传入 FavouriteContentResolver.resolveByDisplayMangaIds(MangaDao.findWithTagsByIds 一次批量查询,保持选择顺序)。分享 / 下载 / 分类对话框 / 编辑 override 都先解析再执行;MARK_AS_COMPLETED 改成传 entity ids 由 VM 解析(MarkAsReadUseCase 需要真实 projection)。置顶 / 移除 / entity organize 选择继续用 row.localMangaIds(空则回退 displayMangaId→entityId),与旧 expandGroupedIds() 等价。
4.3 其余改动
FavouritesListViewModel:删掉Pager/BatchMappingPagingSource/WorkAggregateRepository/ContentListMapper/SourceGroupManager/SourcePresetsRepository/FavouriteItemLookup/refreshTrigger/mapFavouritePage/toGroupedListModel/groupSuffix/sortOrder/setSortOrder(order);pagingContent = null、hasMoreItems = false、onRefresh()变成 no-op(Room invalidation 驱动);Factory 变成create(categoryId, libraryState);Quick Filters(FavoritesListQuickFilter)与 space 绑定原样保留。FavoritesListScreen:retainPagingSnapshotOnDetailsNavigation = false(不再有 retained window;saveable 的 LazyListState/LazyGridState 是唯一真相),详情跳转initialProjectionLocalMangaId = preferred ?: content.id.takeIf { it != entityId }。ContentListMapper.tagTint(title)公开(原来只有私有getTagTint(ContentTag)),供详细列表 tag chips 复用同一份 warn-list 着色。
4.4 有意留下的偏差 / 待办(重要)
metadata authority(tracking 站点)派生的标题/封面 override 与→ Phase 6 已修复(见 §4.5),原文如下。旧链路metadataTrackingServicebadge 丢失resolveDisplayOverride会把「显示元数据权威=某追踪站点」的作品换成 tracking 缓存里的 title/cover,并画一个服务 badge;row 里没有这些列。修复方式:给observeFavouriteCardBaseRows()增补ep.metadata_source_service/remote_id并LEFT JOIN tracking_site_items(PK=(service,remote_id),最多一行,不会放大基数)→ row 加 3 个原语字段 → mapper 按 manual > tracking 合并。Room KSP 会静态校验该 SQL;仍需真机跑FavouriteLibraryReadDaoTest/SnapshotStoreTest(androidTest 里构造FavouriteCardBaseRow的测试要同步加参数,compileDebugAndroidTestKotlin可先验编译)。Quick Filter chips 仍逐分类查 DAO→ Phase 6 已改为内存派生(见 §4.5),原文如下。(FavoritesListQuickFilter,suspendLazy每 VM 一次,切分类会新建 VM→新查询),且它init{}里的「离线时自动勾选 Downloaded」副作用要一并迁移;Filter Panel 仍走activeFavouritesViewModelRef桥接。→ 与 Phase 6 一起做:chips 由snapshot.quickFilterMetadata(+分类切片)在内存派生。- reorder 场景没有 semantic anchor(计划 §6.1 第 4 点,明确列为 Phase 8 按需项):详情返回用普通 saveable 索引恢复,列表未变化时精确;筛选/排序变化后按新顺序从同一 offset 开始。
→ 随逐分类 VM 一起删除。getEmptyState()仍是私有死代码
4.5 Phase 6 已完成:容器是唯一状态持有者(2026-09-02)
计划 §11 Phase 6 的退出条件(收藏页只有一个 screen-level state holder)达成:逐分类 FavouritesListViewModel 与其 @AssistedInject Factory、spaceViewModelKey("favorites-$categoryId", spaceId) 逐分类 Hilt key、getEmptyState() 死代码全部删除。
4.5.1 共享路由的 host seam
AppContentListRoute 原本硬性要求 VM : ContentListViewModel(一个 ViewModel)。抽出接口 list/ui/ContentListHost.kt——只声明路由真正用到的成员(content/pagingContent/hasMoreItems/isLoading/listMode/gridScale/onError/onContentMessage/onContentActionHostRequest/currentSourceTags/currentGroupTab + onRefresh/onRetry/onContentClick/setSelected*/resolve*IdForUiItemId),ContentListViewModel 实现它,路由参数改为 viewModel: ContentListHost。其余页面(history/updates/search/recommend…)继续传 ViewModel,行为不变。rememberRetainedPagingSnapshotState 的 host 参数改为可空,路由传 viewModel as? RetainedPagingSnapshotHost(收藏页 retainPagingSnapshotOnDetailsNavigation = false,本就不需要 retained window)。
4.5.2 FavouritesListHost(favourites/ui/list/)取代逐分类 VM
普通类(非 ViewModel、无 Hilt),实现 ContentListHost + QuickFilterListener,只做切片:content = combine(container.libraryState, container.observeListModeWithTriggers()) → FavouritesCardMapper.map(...)、topQuickFilter / popupQuickFilter、resolveEntityIdForUiItemId(id) = id、resolvePreferredLocalMangaIdForUiItemId。所有动作转调 Container。实例由 FavouritesContainerViewModel.listHost(categoryId) 缓存(@Synchronized getOrPut),因此卡片映射在重组、切分类、旋转后都复用,随容器(= 收藏屏,且 shell 用 space-scoped key 取容器 → 换 space 会重建)一起销毁。
4.5.3 容器吸收的东西
- 新增注入:
FavouritesCardMapper、FavouriteContentResolver、FavoritesListQuickFilter.Factory、MarkAsReadUseCase、TrackingRepository、TrackWorker.Scheduler、@LocalStorageChanges SharedFlow<LocalContent?>(mangaDataRepository改为字段)。 - 新增成员:
listScope(viewModelScope+Default)、gridScale、onContentMessage/onContentActionHostRequest(路由收集的消息/宿主请求)、observeListModeWithTriggers()(模式 + badges/progress/tracker/overrides/favorites/local-storage 触发器)、isSpaceBound()。 - 新增动作(原逐分类 VM 的动作,entity-id 语义不变):
removeFromFavourites(categoryId, ids)、isPinned/setPinned/togglePinned、markAsRead、resolveSelectedContents、resolveSelectionToMangaIds、checkForUpdates()(TrackWorker.Scheduler.requestCheckNow闸口 + 汇总 toast)、私有expandToMangaIds(localMangaIds → displayMangaId → entityId 回退链)。 - Space:改由屏级绑定——
FavoritesHostScreen里LaunchedEffect(viewModel, spaceId) { viewModel.bindSpace(spaceId) }(shell 已用spaceBoundHiltViewModel绑过一次,重复 set 同一 id 是 no-op);逐分类spaceBinding消失,group tab 的 space 作用域只剩容器一份。
4.5.4 Quick Filter chips 改内存派生(§4.4.2)
- 纯函数
favourites/domain/library/FavouritesQuickFilterOptions.kt:buildFavouritesFilterOptions(FavouritesQuickFilterInput),无 DAO、无 I/O;JVM 测试FavouritesQuickFilterOptionsTest(tag top-3、分类只统计本分类 membership、chip 的tagId与 deriver 匹配的 id 同一个、settings 门)。 - 快照 facet 现在带 tag 身份:
FavouriteFacetTag(tagId, title, key, source)(toContentTag()必须回到同一个tagId),tag facet 查询多 selectt.key/t.source。 FavouriteLibraryUiState增加membershipsByCategory/allEntityIds(直接引用快照字段,零拷贝):chips 统计的是未经快筛的分类 membership,所以点掉一个 chip 不会让兄弟 chip 消失。- tag chips 保留旧查询的 3 个上限与
数量 DESC, 标题(忽略大小写) ASC排序;source chips 按数量 DESC, 名称 ASC。有意偏离一处:两者都按过滤器真正匹配的字段计数(row 的 tagIds / 显示投影 source),旧 SQL 用 anchor∪binding 并集计数,可能给出过滤后为空的 chip。 FavoritesListQuickFilter不再查 DAO(create(categoryId, libraryState));FavouritesRepository.findQuickFilterMetadata与其QuickFilterMetadata类型删除。WorkFavouritesDao.findQuickFilterTags/findQuickFilterSourceNames至此无主代码调用方(只剩WorkPagingDaoTest的 characterization 用例)→ Phase 7 一起删。- 「离线自动勾选 Downloaded」从每分类 VM 的
init{}上移到容器init(一次/屏,而不是每 tab 一次)。
4.5.5 显示元数据权威(§4.4.1)
observeFavouriteCardBaseRows() 增补 ep.metadata_source_kind/service/remote_id 并 LEFT JOIN tracking_site_items ON (service, remote_id)(PK 唯一,不放大基数;join 条件带 metadata_source_kind = 'tracking',权威切回本地时列全 NULL)。FavouriteCardBaseRow 加 metadataTrackingService/Title/CoverUrl,FavouriteCardRow 同名三字段;mapper 优先级 manual override > tracking 站点缓存 > 投影,tracking 的 service 也用于详情 badge。JVM:FavouriteCardModelTest/FavouritesCardMapperTest 覆盖 manual>tracking 与「无缓存行→全 NULL」;androidTest:FavouriteLibrarySeed.insertPrefs(metadataSourceKind/metadataService/metadataRemoteId) + insertTrackingSiteItem,FavouriteLibraryReadDaoTest.metadataAuthorityReadsTheCachedSiteItem 覆盖 tracking 命中 / 缓存缺失 / kind=base 的守卫。
4.6 Phase 7 已完成:删除收藏 Paging 链路(2026-09-02)
计划 §8 Phase 7 清单按「只删确认无其他调用方」执行,结果分两类。
已删除(主代码)
favourites/data/FavouriteLibraryPagingRow.kt(整文件):宽行(内嵌整个MangaEntity+WorkHistoryEntity+ tracking 列)随收藏 Paging 一起退休。WorkFavouritesDao.pagingSource()(含那段宽@Query)、FULL_LIST_PAGE_SIZE、import androidx.paging.PagingSource;顺带删掉 Phase 6 起就无主代码调用方的findQuickFilterTags()/findQuickFilterSourceNames()(chips 已内存派生)。WorkAggregateRepository.createFavouritePagingSource()、observeFavouriteAggregates()(两者零调用方)、buildFavouritePagingAggregates()、FavouriteLibraryPagingRow.toTrackingSummary()。core/paging/BatchMappingPagingSource.kt里的FavouriteLibraryPagingConfig(LargeLibraryPagingConfig与BatchMappingPagingSource本身留给历史页)。- 收藏页删掉
retainPagingSnapshotOnDetailsNavigation = false(路由默认已是 false,收藏不再启用 retained window)。
改写而非删除
WorkFavouritesDao.findList(...):签名不变,但不再是「pagingSource+ 全量翻页收集」的包装,而是同一个@Query直接把投影从selected.*, ep…, m…, wh…, tracking…收窄成selected.*,返回List<WorkFavouriteEntity>。原因:findList不是「只服务收藏 UI 分页」的包装——它撑着findFavouriteEntries → findFavouriteAggregates/findFavouriteContents,而后者仍被FavouritesRepository.getAllContent/getLastContent/buildWorkFavourite*Contents/Covers(小组件、备份、通知等)使用;那些调用方从来只要 entity 行。Room KSP 已静态校验改写后的 SQL。
测试
- 删除
FavouriteLibrarySemanticsCharacterizationTest(24 用例)与FavouriteLibraryBaselineBenchmarkTest(3 用例):它们 pin 的是被删掉的宽行分页 SQL 与旧链路基准(基准数字已归档在 §1)。 WorkPagingDaoTest:删favouriteColdStartLoadsOnlyTheInitialWindow、favouritePageCarriesDisplayHistoryAndTrackingSummary(用pagingSource)与favouriteQuickFilterMetadataUsesDatabaseAggregation(用已删的 chips 查询)。保留favouritesListAndHistoryPageByUniqueEntity、favouriteRepresentativeCarriesPreferredProjectionInTheSameQuery、favouriteFullListBenchmarkFitsBudget、favouriteFiltersAreAppliedBeforePaging——最后一组经favouriteList()助手直接跑改写后的findList,是它现在的实机覆盖。历史/更新页的 Paging 用例与BatchMappingPagingSourceTest其余用例不动(只删了 favourites config 断言)。- §3 的语义现在由新链路自己固化:代表 membership 与排序在
FavouriteLibraryReadDaoTest(11)+FavouriteLibraryDeriverTest(18),快照组装语义(binding-based 投影、broken row 保留、override 优先级、readingStatus)在FavouriteLibrarySnapshotStoreTest(9),findList侧仍在WorkPagingDaoTest。
没删(计划明令保留):WorkAggregateRepository 其余领域用途(observeFavouriteLibraryAggregates 还撑 Phase 8 的 shadow comparison、findFavouriteAggregates/findFavouriteContents 撑小组件与备份)、历史页 Paging 基础设施、RetainedPagingSnapshotController 与通用 semantic anchor、Room 表/备份格式/entity identity。
与计划的一处偏差:§8 要求「rg 确认 favourites 包无 Paging import」。实际剩一处:FavouritesListHost.pagingContent = null 需要 androidx.paging.PagingData 才能满足 ContentListHost 契约(共享路由的接口成员,收藏永远不填它)。
4.7 下一步(Phase 8)
Phase 6 已完成(见 §4.5)、Phase 7 已完成(见 §4.6,设备侧 WorkPagingDaoTest 待补跑)。Phase 8:跑计划 §11 全部基准 + 与 Komikku 并列对照 + 删 shadow comparison/临时日志 + 更新 large-library-performance-handoff-2026-08.md。
设备必测(Phase 5/6 改动直接覆盖收藏页,见 §5 流程):收藏四模式渲染、分类/快筛/排序切换(logcat 确认 FavouriteLibraryReadDao 不重复查询)、详情返回、置顶/移除/标记完成/分享/下载/分类对话框/编辑 override/实体整理、broken row 显示与跳转、多分类下 pin badge 只在本分类出现。
Phase 6 追加:切分类/旋转后卡片不重置(host 由容器缓存)、top-bar filter panel 的快筛仍作用于当前 tab(activeFavouritesHostRef)、切 space 后容器重建且列表按 space 作用域、离线时进入收藏页自动勾选 Downloaded(现在一次/屏而不是每 tab 一次)、tracking 权威作品的标题/封面与站点 badge。
5. 设备/构建备忘(踩过的坑)
- androidTest 类名不能含空格(R8/dex 限制):反引号方法名用驼峰。
- MIUI 真机 orchestrator 崩溃(StatusCode 134 / "Unable to find instrumentation info"):
connectedDebugAndroidTest不可靠。可靠流程:bash./gradlew :app:packageDebug --rerun-tasks # 主 APK 有新类时必须强制重打包(UP-TO-DATE 判断不可靠) ./gradlew :app:assembleDebugAndroidTest adb install -r -t app/build/outputs/apk/debug/app-arm64-v8a-debug.apk adb install -r -t app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk adb shell am instrument -w -e class <全限定类名> org.skepsun.kototoro.debug.test/org.skepsun.kototoro.HiltTestRunner - Gradle
connectedDebugAndroidTest一定失败:pm path androidx.test.services为空 → AGP shell executor 的app_process直接 SIGABRT(StatusCode: 134/Starting 0 tests),而且这次运行会顺手卸掉主 APK,之后am instrument报Unable to find instrumentation target package: org.skepsun.kototoro.debug。用上面的am instrument流程;若 target 丢了就重新adb install -r -t app-arm64-v8a-debug.apk(用pm path org.skepsun.kototoro.debug确认装上了)。 adb: device 'xxx' not found之后若所有 adb 调用都变成failed to check server version: protocol fault (couldn't read status),说明 5037 上留下了无人拥有的半死监听套接字(ss -ltnp | grep 5037有 LISTEN 但pgrep adb为空、adb kill-server无效)。不必重启机器:换端口起一个新 server 即可——ANDROID_ADB_SERVER_PORT=5039 adb devices(后续 adb 命令都带上这个环境变量)。- 交互 UI 验证在 adb 侧不可做:
adb shell input抛SecurityException(MIUI 需另开「USB 调试(安全设置)」并登录账号),android layout --pretty会挂死(>150s 无输出),adb shell wm dismiss-keyguard+svc power stayon true只能把屏幕从 Dozing 叫醒、锁屏 PIN 仍在(screencap 得到锁屏图)。所以 §4.7 的设备清单必须人工在手机上进;自动侧能做到的上限是 Room/无 UI 的 androidTest(FavouriteLibraryReadDaoTest11 用例、FavouriteLibrarySnapshotStoreTest9 用例在 0.5s 内跑完)。 - JVM 单测的
assertEquals解析到 JUnit4org.junit.Assert:message 必须放最后:assertEquals(expected, actual, "msg")。 - 种子数据:
preferences表 7 个 NOT NULL 无默认值列(manga_id/mode/cf_brightness/cf_contrast/cf_invert/cf_grayscale/cf_book)必须显式 INSERT;manga.cover_urlNOT NULL。 - Room DAO 测试用
Room.inMemoryDatabaseBuilder(...).build()+ raw SQL 种子(见FavouriteLibrarySeed),不要用 Hilt 真库。 - kotlinx
combine6+ flow 必须用Array<*>签名 + 显式 cast。 - JDK 24 可用;设备
ecd4369c(M332BF,Android 16/API 36)。
6. 提交归属(本次推送)
本次重构的提交(按计划 §17 拆分,只含重构相关文件):
test(favourites): capture library semantics and performance baseline— Phase 0 测试 + 种子 + 2 篇文档(计划/基线)feat(favourites): add lightweight library read projections— Phase 1 DAO + rows + MangaDatabase 注册 + DAO 测试feat(favourites): add observable library snapshot store— Phase 2 store + 模型 + 测试refactor(favourites): derive filters grouping and sorting in memory— Phase 3 deriver + GlobalTagBlacklist.containsTagTitle + 测试refactor(favourites): move library state ownership to container— Phase 4 Container + UiState + 测试
留在工作区不提交(用户自己的未提交改动,Phase 7/8 处理):
core/paging/BatchMappingPagingSource.kt(enablePlaceholders=true 实验)→ 工作区里这两个文件现在的 diff 全部是 Phase 6 的改动(list/ui/ContentListViewModel.kt/list/ui/compose/RetainedPagingSnapshotController.kt(K510G debug 日志)ContentListHost实现 + retained host 可空),debug 日志已不在。backups/external/*、core/AppModule.kt、details/ui/compose/*、docs/.vitepress/config.mts、docs/index.md、backups/external/MihonBackupSourceIdPreservationTest.kt、core/paging/BatchMappingPagingSourceTest.ktdocs/architecture/komikku-library-list-architecture-research-2026-09.md、docs/architecture/paging3-lazy-list-alternatives-research-2026-09.md(研究文档,归属由用户决定)
7. 验证命令
./gradlew :app:compileDebugKotlin
./gradlew :app:compileDebugAndroidTestKotlin # androidTest 只需编译(本会话无设备)
./gradlew :app:testDebugUnitTest --no-daemon
./gradlew :app:testDebugUnitTest --tests "org.skepsun.kototoro.favourites.*" --no-daemon
# 设备(见 §5 流程):
# FavouriteLibraryReadDaoTest / FavouriteLibraryReadDaoScaleTest
# FavouriteLibrarySnapshotStoreTest / FavouriteLibrarySnapshotStoreScaleTest
# FavouriteLibraryAggregateChainCharacterizationTest / WorkPagingDaoTest
# (FavouriteLibrarySemanticsCharacterizationTest 与 FavouriteLibraryBaselineBenchmarkTest 已在 Phase 7 删除)Phase 6 之后(2026-09-02,接了手机但只能跑无 UI 的 androidTest):compileDebugKotlin ✓、compileDebugAndroidTestKotlin ✓、testDebugUnitTest 全量 2148/2148 ✓(favourites 包 90:新增 FavouritesQuickFilterOptionsTest 4、FavouritesCardMapperTest 14)。设备(am instrument 直跑,见 §5):FavouriteLibraryReadDaoTest 11/11(含 metadataAuthorityReadsTheCachedSiteItem)、FavouriteLibrarySnapshotStoreTest 9/9。冷启动新 APK 无 crash(adb install + monkey 起 MainActivity,logcat 无 AndroidRuntime FATAL)。§4.7 的交互清单(含 Phase 6 追加项)仍需人工在手机上跑——本会话 adb 无法注入输入(见 §5)。
Phase 7 之后(2026-09-02,本机补跑):compileDebugKotlin ✓(Room KSP 校验改写后的 findList SQL)、compileDebugAndroidTestKotlin ✓、testDebugUnitTest 全量 ✓(删了 BatchMappingPagingSourceTest 的 favourites config 断言)。设备侧:另一台机器装机后 adb 掉线遗留的 WorkPagingDaoTest 已在本机(M332BF,核心破解跨签名安装本机构建 APK)补跑通过 12/12(含改写后 findList 的实机覆盖);全部收藏套件用本机构建复跑 35/35(ReadDao 11 + Scale 2 + SnapshotStore 9 + StoreScale 1 + AggregateChain 12);冷启动 MainActivity 无 FATAL、进程存活。adb 输入注入仍被 MIUI INJECT_EVENTS 拦截,§4.7 交互清单(收藏页进出、分类切换、多选、详情返回等)需人工在设备上完成。
全部通过状态(2026-09-01):JVM 32/32(契约 10 + deriver 16 + UiState 6),设备 58/58(SQL 24 + 聚合链 12 + DAO 10+2 + Store 9+1 + 基准 3——基准 3 含在其中)。
Phase 5 之后(2026-09-02,无设备会话):compileDebugKotlin ✓、compileDebugAndroidTestKotlin ✓、testDebugUnitTest 全量 2141/2141 ✓(其中 favourites 包 83:新增 mapper 11、deriver pinned 2、UiState pinned 1)。androidTest 仅验证编译;§4.5 的设备清单是下一次接手机的入口(Phase 6 之后还需加测:切分类/旋转后卡片不重置、filter panel 的快筛仍作用于当前 tab、离线自动 Downloaded)。