从零开始Android商业项目Vibe coding完全指南(八)

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用以指明"前一页"是否已经进行过处理。

总体解析过程如下:

  1. 当前页检测到strategyLandscapeStrategy.UNSPECIFIED,开展解析任务......

  2. 下一页检测到strategyLandscapeStrategy.UNSPECIFIED,但同时检查到isPreviousHandled为false,得知当前页未完成解析,不执行无效解析。

  3. 上一页,同样检测到strategyLandscapeStrategy.UNSPECIFIED,并同时检查到isPreviousHandled为false,但含义不同,需要对这个"上上一页"安排解析任务。

  4. 上上页执行解析任务,完成后对它的"相对下一页"设置isPreviousHandled为true。当上上页顺利做了双页合并时,这个"相对下一页"是"当前页",实际无意义。当"上上页"发现无法做双页合并时,这个"相对下一页"是"上一页"

  5. 当前页完成解析任务。也会对它的"相对下一页"设置isPreviousHandled为true。它的"相对下一页",总是"下一页"。

4与5的先后完成顺序无法预计,但同样会引发重组。

4导致的重组结果:

  • 上上页顺利做了双页合并。当再次进入步骤3,上一页,检测到strategy不为LandscapeStrategy.UNSPECIFIED,据此对LandscapePageItem做运行时转化得到AutoDual,然后按策略加载合并页面供显示。

  • "上上页"发现无法做双页合并。当再次进入步骤3,上一页,检测到strategyLandscapeStrategy.UNSPECIFIED,但isPreviousHandled为true,因此可以对上一页执行解析任务......

5导致的重组结果:

  • 当再次进入步骤1,当前页,检测到strategy不为LandscapeStrategy.UNSPECIFIED,据此对LandscapePageItem做运行时转化得到AutoDual,然后按策略加载合并页面供显示。

  • 当再次进入步骤2,下一页,检测到strategyLandscapeStrategy.UNSPECIFIED,但同时检查到isPreviousHandled为true,得知当前页已完成解析,因此执行解析任务......

综上,初始完成三页加载需要触发四次重组。

防止任务重复触发

如前所述,4与5的先后完成顺序不可预计,你会发现,当5先完成时一切正常,而4先完成则会出现错误,这也就是并发编程中所谓的竞态条件

显然,当4比5先完成时,触发重组后再次进入步骤1,将会由于当前页检测到strategyLandscapeStrategy.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的附加功能。

相关推荐
火锅小王子1 天前
从 0 到 1:我用 AI Coding 撸了一套带「智能客服」的全栈电商系统
agent·vibecoding
何智超1 天前
AI 微前端性能优化之旅(上):复盘
前端·vibecoding
搞Ai的小月月2 天前
给 AI 装个"设计大脑":UI-UX-Pro-Max-Skill 实测与接入笔记
vibecoding
_山海3 天前
OpenSpec-基于SDD规格驱动开发
ai编程·vibecoding
_山海5 天前
Claude Code for VS Code插件安装与CC Switch配置
vibecoding
kunge20135 天前
深度剖析Claude Code 的CLAUDE.md加载逻辑
后端·vibecoding
Bigger5 天前
Tauri (26)——托盘图标总对不上系统主题?一行 Template Image 搞定
前端·rust·app
极客密码6 天前
来看看我用Codex两周时间vibe coding的这款轻量级的剪贴板管理应用,win/mac系统均可用
前端·ai编程·vibecoding
程序员老刘6 天前
跑分第一的编程大模型,我为啥不用?
flutter·ai编程·vibecoding