20260630
读者应具备的知识基础
Kotlin、 Compose、 基础Android开发(谷歌官网课程优先),并发编程(推荐书目《Java编程的逻辑》)
添加目录改变时的过渡动画
纯Compose有一点远远强于纯View,那就是动画效果。做起来实在简单太多,动画API也更强大。唯一的问题就是学习成本。不过,现在有了AI,不学也无所谓,这是最适合Vibe Coding的领域了。
趁这个机会赶快Vibe coding一下,不然本教程要完全不符合标题了:)
让我们给目录切换,也就是进出文件夹时添加一个过渡动画,由于不确定AI的能力,第一条指令这样下:
LazyVerticalGrid 发生数据源改变,有无办法做个切换动画让过渡更美观?
它回答还算有谱,给了这个示例:
kotlin
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable
@Composable
fun CrossfadeGridExample(currentCategoryData: List<MyData>) {
// 使用 AnimatedContent 监听整个数据列表(或数据状态分类)的变化
AnimatedContent(
targetState = currentCategoryData,
transitionSpec = {
// 定义淡入淡出的交叉渐变动画
fadeIn(animationSpec = tween(400)) togetherWith fadeOut(animationSpec = tween(400))
},
label = "GridTransition"
) { targetData ->
// 当数据源改变时,旧的 Grid 会淡出,新的 Grid 会淡入
LazyVerticalGrid(
columns = GridCells.Fixed(2)
) {
items(targetData) { item ->
MyGridItemUI(item)
}
}
}
}
基于此,我们把这个基础代码加入,继续追问:
@BookshelfScreen.kt AnimatedContent( targetState = subFileList, transitionSpec = { // 定义淡入淡出的交叉渐变动画 fadeIn(animationSpec = tween(400)) togetherWith fadeOut(animationSpec = tween(400)) }, label = "GridTransition" ) { targetData -> 这里做的当LazyVerticalGrid数据源变化时的过渡动画效果不好,你改一下:旧数据带景深缩小向右移出,而新数据从右往左带景深移入
AI一通修改之后,效果出来了:

自动双页的逻辑推演
需求描述:在横屏状态下让双页合并,一方面是让跨页合并,另一方面是充分利用横屏宽广的视野。
不同于自动单页,这个反向操作要棘手得多:
自动单页时,双页就拆,单页不动,对所有页面处理的最终结果唯一;但是自动双页,结果并不唯一,举例来说,可以让单页1和2合并,但这可能并非读者需要的,单页2、3合并可能才是正解。
对N页全是单页的书籍,双页合并的可能有N-1种,即每一页与它的后页合并,仅最后页没法合并。
在之前纯View的实现中,我做的是基于运行时的实现方案,在命令式UI下这自然且符合常理,可到了Compose下,这将完全不符合声明式UI的特性。
不过,有几条自动单页时用过的设计原则仍旧通用,归纳之后自动双页的设计如下:
- 固化双页合并方式为本页与下页尝试合并
- 使用ViewModel执行解析任务,这样将不会受到重组的影响。解析任务的完成,将会导致数据源列表改变,从而触发重组
- 初始解析前页、当前页、下页共三页
- 对当前页直接尝试合并
- 对下页,需要等待上一页,也就是当前页的解析结果。假设当前页顺利做了双页合并,那么会造成数据源列表减一,下页作用的页码需要后移一位
- 对上页,不需要等待当前页的合并结果,但由于第一条原则的存在,如果要合并双页其作用的页码需要前移一位。也就是说,它跟下页一样需要顾及"前面一页"的情况。
- 对已经完成解析的页面,实际加载显示。
可以看出,在这种设计模式下,需要细化处理三个页面的不同解析模式。
在上一节中已经说过,我们的自定义Compose是一个初始左中右排列的三页UI,你可能会疑惑这种设计无法达成极致性能:第一个解析的是左边的页面,而不是显示在屏幕中的中间页?但实际情况并非如此。
附加知识:Compose函数的执行顺序不以代码书写位置为准
你或许已经知道:Compose本身有对UI加载做顺序优化,也就是说,Compose的函数调用不以代码顺序为准,而是以出现在屏幕绘制范围内的优先级为准。比如:
kotlin
val content: @Composable (() -> Unit) = {
val leftPageIndex = getLeftPagePos(isRtl, pageIndex)
val rightPageIndex = getRightPagePos(isRtl, pageIndex)
logE("--- In content state.currentMiddlePos:${state.currentMiddlePos} currentPageIndex:$pageIndex")
repeat(3) { physicalSlot ->
// 在 Compose 中,识别一个 Composable 函数是否可以"跳过"不仅取决于它的参数(Key)是否相等,还取决于它在 插槽表(Slot Table) 中的位置 ID。这个位置 ID 是由编译器根据源代码的结构生成的。
// 1. 先计算出这个插槽对应的逻辑页码
val targetPageIndex = when (getLocation(physicalSlot, state.currentMiddlePos)) {
PageLocation.LEFT -> {
logE("--- LEFT physicalSlot:$physicalSlot leftPageIndex:$leftPageIndex")
leftPageIndex
}
PageLocation.MIDDLE -> {
logE("--- MIDDLE physicalSlot:$physicalSlot pageIndex:$pageIndex")
pageIndex
}
PageLocation.RIGHT -> {
logE("--- RIGHT physicalSlot:$physicalSlot rightPageIndex:$rightPageIndex")
rightPageIndex
}
}
val key = if (targetPageIndex in 0 until pageCount) targetPageIndex
else -1
// logE("--- key:$key")
// 3. 在 when 分支外部统一调用(确保调用点 ID 固定)
ReusablePage(key, pageContent)
}
}
虽然content这个Compose方法有循环三次,但并不意味着第一次调用的ReusablePage(key, pageContent)会第一个执行,如上一节所述,在调用它的自定义Compose中,三个页面初始以左中右顺序排列,因此第一次调用的ReusablePage(key, pageContent)实际上是布局在屏幕左侧,属于并不需要绘制的部分,而第二次调用的ReusablePage(key, pageContent)由于完整充斥整个屏幕,因此会第一个执行。
数据结构
针对设计原则,我们的数据结构对比自动单页,需要做出适当微调:
kotlin
/**
* @param isPreviousHandled 辅助标记,由于双页逻辑总是这页与下页合并,因此初始加载时下页需要等待当前页完成处理后才能加载,
* 在恰当的时机处理这个标志位让AutoDual产生变化避免跳过重组
* */
data class LandscapePageItem(
val pageIndex: Int = 0,
val strategy: LandscapeStrategy = LandscapeStrategy.UNSPECIFIED,
val isPreviousHandled: Boolean = false
)
enum class LandscapeStrategy {
UNSPECIFIED, ORIGINAL, DUAL
}
fun LandscapePageItem.toAutoDual(isRtl: Boolean): AutoDual {
return when (strategy) {
LandscapeStrategy.UNSPECIFIED -> AutoDual(
pageIndex,
AutoDualStrategy.UNSPECIFIED,
isPreviousHandled
)
LandscapeStrategy.ORIGINAL -> AutoDual(
pageIndex,
AutoDualStrategy.ORIGINAL,
isPreviousHandled
)
LandscapeStrategy.DUAL -> AutoDual(
pageIndex,
if (isRtl) AutoDualStrategy.DUAL_RTL else AutoDualStrategy.DUAL_LTR,
isPreviousHandled
)
}
}
data class AutoDual(
val pageIndex: Int,
val strategy: AutoDualStrategy,
val isPreviousHandled: Boolean
)
fun AutoDual.isUnspecified() = strategy == AutoDualStrategy.UNSPECIFIED
enum class AutoDualStrategy {
UNSPECIFIED, ORIGINAL, DUAL_LTR, DUAL_RTL
}
LandscapePageItem是数据源列表中的存储结构,而AutoDual是在运行时根据阅读方向参数转化的实际解析结构。当strategy不为LandscapeStrategy.UNSPECIFIED时,说明已经进行过处理,可以根据strategy实际解析页面。另外还添加了val isPreviousHandled: Boolean = false用以指明"前一页"是否已经进行过处理。
总体解析过程如下:
-
当前页检测到
strategy为LandscapeStrategy.UNSPECIFIED,开展解析任务...... -
下一页检测到
strategy为LandscapeStrategy.UNSPECIFIED,但同时检查到isPreviousHandled为false,得知当前页未完成解析,不执行无效解析。 -
上一页,同样检测到
strategy为LandscapeStrategy.UNSPECIFIED,并同时检查到isPreviousHandled为false,但含义不同,需要对这个"上上一页"安排解析任务。 -
上上页执行解析任务,完成后对它的"相对下一页"设置
isPreviousHandled为true。当上上页顺利做了双页合并时,这个"相对下一页"是"当前页",实际无意义。当"上上页"发现无法做双页合并时,这个"相对下一页"是"上一页" -
当前页完成解析任务。也会对它的"相对下一页"设置
isPreviousHandled为true。它的"相对下一页",总是"下一页"。
4与5的先后完成顺序无法预计,但同样会引发重组。
4导致的重组结果:
-
上上页顺利做了双页合并。当再次进入步骤3,上一页,检测到
strategy不为LandscapeStrategy.UNSPECIFIED,据此对LandscapePageItem做运行时转化得到AutoDual,然后按策略加载合并页面供显示。 -
"上上页"发现无法做双页合并。当再次进入步骤3,上一页,检测到
strategy为LandscapeStrategy.UNSPECIFIED,但isPreviousHandled为true,因此可以对上一页执行解析任务......
5导致的重组结果:
-
当再次进入步骤1,当前页,检测到
strategy不为LandscapeStrategy.UNSPECIFIED,据此对LandscapePageItem做运行时转化得到AutoDual,然后按策略加载合并页面供显示。 -
当再次进入步骤2,下一页,检测到
strategy为LandscapeStrategy.UNSPECIFIED,但同时检查到isPreviousHandled为true,得知当前页已完成解析,因此执行解析任务......
综上,初始完成三页加载需要触发四次重组。
防止任务重复触发
如前所述,4与5的先后完成顺序不可预计,你会发现,当5先完成时一切正常,而4先完成则会出现错误,这也就是并发编程中所谓的竞态条件。
显然,当4比5先完成时,触发重组后再次进入步骤1,将会由于当前页检测到strategy为LandscapeStrategy.UNSPECIFIED,开展解析任务......
如果解析任务的结果发现可以双页合并,那么数据源列表将会改变,导致总逻辑页面数减一,以及可能带来逻辑位置的改变。因此,重复的解析任务,必将会造成逻辑错误。
我们使用一个集合来保存任务,并在加载解析任务时做校验,如果已经在任务集合中,就不再执行重复任务了。代码如下:
kotlin
private val autoDualTaskSet = HashSet<Int>()
fun handleUnspecifiedPageItemAutoDual(pageIndex: Int) {
if (autoDualTaskSet.contains(pageIndex)) return
viewModelScope.launch(Dispatchers.Main) {
autoDualTaskSet.add(pageIndex)
logE("--- autoDualTaskSet.add(pageIndex) pageIndex:$pageIndex")
val cache = getRawBitmapInLruCache(pageIndex)
if (null != cache) {
updateLandscapePageSourceDependsOnBitmap(cache, pageIndex)
} else {
val bitmap =
withContext(Dispatchers.IO) { loadBitmapByPageIndex(pageIndex) }
if (null != bitmap) {
putBitmapToLruCacheSynchronized(pageIndex, bitmap)
updateLandscapePageSourceDependsOnBitmap(bitmap, pageIndex)
}
}
autoDualTaskSet.remove(pageIndex)
logE("--- autoDualTaskSet.remove(pageIndex) pageIndex:$pageIndex")
}
}
至此,主体逻辑已经相当完整了,相信你一定可以轻易实现自动双页功能,但目前的AI却做不到。
其实现效果如下:

下一节,我们或许需要找一点能够Vibe coding的附加功能。