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

20260529

读者应具备的知识基础

Kotlin、 Compose、 基础Android开发(谷歌官网课程优先)

关于提示词

如果你的编程经历够长,那么你就可能经历底层库错误、编程语言错误、编译器错误,在这些时候你都没有犯错,是它们拖累了你。

AI在我眼中不过是件工具,活干不好我会认为是它的问题;但确实有一些人在神话AI,把它供奉起来,一出问题就要从其他方面找原因。

比如,质问你为什么没有订阅最昂贵、最新潮、最强大(这一点存疑)的大模型?

为什么没有打造多Agent AI军团?

为什么没有给AI配上XXXX软件?

为什么没有配置XXX文件?

为什么没有布置好CLI,没有安装海量的SKILL?

以及,你怎么能这样写提示词?你这样写完全错误根本发挥不出AI的神力!

这些质问的话术之中,唯有最后一点是属于个人的,因此有些人会自诩"提示词大师",表示不是AI不行,是他人不行,只有大师才能用好AI。

在目前的时间点上,这些行为实在无比可笑。

还记得我们在第一节曾说过什么才是Vibe coding吗?

核心定义 Vibe Coding 强调"结果导向"与"人机协作"。开发者不再是代码的直接制造者,而是演变为产品的"定义者"、"评审者"和"指导者" 。你向 AI 描述你的需求和意图,AI 则负责完成底层的逻辑实现。

显而易见,目前的AI距离真正的Vibe coding还有着巨大的能力鸿沟。因此如果不是技术人员,将非常难以构筑出商业级项目。

你可以看到,对于这种能力不足,Google给出了提示词最佳实践。例如:

想必你注意到了,本博文中绝大部分情况下,我几乎都是上图中的"错误做法"。而左边的所谓"正确做法",都快赶上伪代码了。这倒不是我故意跟Google对着干,我纯粹只是选择了对自己最舒适的方式,通过提示词驱动AI罢了。

我建议你也这么做:按自己的喜好,提示词怎么舒服怎么来。

不要花费过多精力去研究提示词以讨好AI,这不值得,特别是技术进步越快,提示词技巧就越会一文不值。

Vibe Coding 全页俯瞰功能

精确化提示词

某些时候的Vibe coding确实需要精确化提示词。比如说你需要修改特定位置的UI,我会找到关代码直接复制指定:

@BookshelfScreen.kt 第697行开始的: Box( modifier = Modifier .fillMaxWidth(0.5f) .height(4.dp) .clip(RoundedCornerShape(2.dp)) .background(MaterialTheme.colorScheme.primary) ) 用于显示阅读进度,完成阅读进度显示功能,你需要将每本书的进度系数填进去,比如没有阅读过的 .fillMaxWidth(0f), 读了一半的 .fillMaxWidth(0.5f)

短短几句话,Gemini领会并完成了阅读进度显示:

使用图片附件

接下来这个功能,语言难以描述清楚,我们改用画面。这是我在纯View版本中实现过的一个"全页俯瞰"功能,也就是点击页码指示UI后显示的一个Dialog,里面装满了整本漫画的小图预览:

这个Dialog适配了左右方向,以及大小屏设备的自适应布局:平板上显示5列,手机上显示3列。

太久没有自己写代码,感觉会生锈,我就自己动手写了通用UI组件,并用AI补正了下------这也是我建议你自行处理的代码 ------BaseAutoSizeDialog

kotlin 复制代码
@Composable
fun BaseAutoSizeDialog(
    isPhone: Boolean,
    onDismissRequest: () -> Unit,
    content: @Composable () -> Unit
) {
    val widthFraction = if (isPhone) 0.9f else 0.8f
    val heightFraction = if (isPhone) 0.9f else 0.75f

    // 修正为使用 LocalWindowInfo
    val windowInfo = LocalWindowInfo.current
    val containerWidth = with(LocalDensity.current) { windowInfo.containerSize.width.toDp() }
    val containerHeight = with(LocalDensity.current) { windowInfo.containerSize.height.toDp() }

    val maxWidth = containerWidth * widthFraction
    val maxHeight = containerHeight * heightFraction

    Dialog(
        onDismissRequest = { onDismissRequest() },
        // ✨ 关键点 1:禁用系统默认的宽度限制,允许内容撑满屏幕
        properties = DialogProperties(usePlatformDefaultWidth = false)
    ) {
        Card(
            modifier = Modifier
                .widthIn(max = maxWidth)
                .heightIn(max = maxHeight),
            // 圆角:AlertDialog 默认 extraLarge = 28.dp
            shape = MaterialTheme.shapes.extraLarge,
            // 背景色:AlertDialog 使用 surface + tonalElevation
            colors = CardDefaults.cardColors(
                containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
            ),
            elevation = CardDefaults.cardElevation(
                defaultElevation = AlertDialogDefaults.TonalElevation
            )
        ) {
            // 内边距:AlertDialog 默认 24.dp
            Box(
                modifier = Modifier.padding(Dimen.autoSizeAboutDialogPadding)
            ) {
                content()
            }
        }
    }
}

BaseAutoSizeDialog将能根据参数isPhone自适应布局大小。

我们通过这个按钮,把效果图片喂给AI,再输入提示词:

@BrowserScreen.kt 实现图中"全页俯瞰"功能:点击 Button( // 🌟 解决问题的关键:清除按钮内部默认的内边距 contentPadding = PaddingValues(0.dp), onClick = {}, modifier = Modifier .height(Dimen.browserBottomMenuPageIndicatorHeight) .widthIn(min = Dimen.browserBottomMenuPageIndicatorWidth) ) { Text( text = " currentPageIndex+1/ {currentPageIndex + 1}/ currentPageIndex+1/maxPageIndexSize", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onPrimary, ) } 时弹出,这时会隐藏菜单。效果基于BaseAutoSizeDialog实现,显示书籍的所有小图预览,平板5列,手机3列,排列方向会根据阅读方向变化,点击小图时可以跳转到对应页并让该Dialog消失

我们看下效果:

UI美观度暂且放到一边,载入速度着实慢得很啊?

看下AI自己能解决不:

小图预览的加载速度非常慢,你分析下看能否解决

回复如下:

有些地方的处理还是存在问题,我们看下实际效果先:

还行,那我们姑且顺着它思路进一步优化。

简单性能优化

这是AI写的小图加载方法:

kotlin 复制代码
suspend fun getThumbnail(pageIndex: Int): ImageBitmap? {
    val cache = thumbnailLru.get(pageIndex)
    if (null != cache) return cache.asImageBitmap()

    return semaphore.withPermit {
        thumbnailLru.get(pageIndex)?.asImageBitmap() ?: withContext(Dispatchers.IO) {
            // 缩略图请求较低的分辨率 (例如 1/4)
            val thumbWidth = comicPageRepository.screenWidth / 4
            val thumbHeight = comicPageRepository.screenHeight / 4
            val bitmap = comicPageRepository.loadBitmapByPageIndex(
                pageIndex,
                thumbWidth,
                thumbHeight
            )
            if (null != bitmap) {
                thumbnailLru.put(pageIndex, bitmap)
            }
            bitmap?.asImageBitmap()
        }
    }
}

如前所述,其中semaphore它限制为了3,我们把它改成按核心数:

kotlin 复制代码
private val semaphore = Semaphore(Runtime.getRuntime().availableProcessors()) // 限制并发解码数量,防止卡顿

接着,这个1/4屏幕分辨率还是高了点,我们让它尽可能贴近实际高度。调用前算出宽度,然后传入getThumbnail

Screen中:

kotlin 复制代码
       if (uiState.showFullPageOverview) {
            val isPhone = windowSize == WindowWidthSizeClass.Compact
            val thumbnailWidth =
                if (isPhone) {
                    val otherSpace = with(density) {
//                        (24 * 2 + 8 * 2).dp.roundToPx()
                        64.dp.roundToPx()
                    }
                    ((screenWidthPx * 0.9f - otherSpace) / 3).toInt()
                } else {
                    val otherSpace = with(density) {
//                        (24 * 2 + 8 * 4).dp.roundToPx()
                        80.dp.roundToPx()
                    }
                    ((screenWidthPx * 0.9f - otherSpace) / 5).toInt()
                }

            FullPageOverviewDialog(
                isPhone = isPhone,
                currentPageIndex = uiState.currentPageIndex,
                maxPageIndexSize = uiState.maxPageIndexSize,
                readingDirection = settingsUiState.readingDirection,
                onPageSelected = { viewModel.updateCurrentPageIndex(it) },
                onDismissRequest = { viewModel.setShowFullPageOverview(false) },
                getThumbnail = { viewModel.getThumbnail(thumbnailWidth, it) }
            )
        }

ViewModel中:

kotlin 复制代码
suspend fun getThumbnail(thumbWidth: Int, pageIndex: Int): ImageBitmap? {
    val cache = thumbnailLru.get(pageIndex)
    if (null != cache) return cache.asImageBitmap()

    return semaphore.withPermit {
        thumbnailLru.get(pageIndex)?.asImageBitmap() ?: withContext(Dispatchers.IO) {
            val thumbHeight = (thumbWidth * CoverLoader.COVER_HEIGHT_WIDTH_RATIO).toInt()
            val bitmap = comicPageRepository.loadBitmapByPageIndex(
                pageIndex,
                thumbWidth,
                thumbHeight
            )
            if (null != bitmap) {
                thumbnailLru.put(pageIndex, bitmap)
            }
            bitmap?.asImageBitmap()
        }
    }
}

CoverLoader.COVER_HEIGHT_WIDTH_RATIO是1.42f。

添加快速滚动条,并修正开源库Bug

如果漫画的页数很多,显然需要有个快速滚动条,"全页俯瞰"功能才能带来更好的体验。

我们选用开源库LazyColumnScrollbar来完成这一功能。

调包这种低级操作这里就不废话了。问题在于,加入快速滚动条后,你会发现当漫画页数在一百多页时,上下滑动列表时scrollbar会出现明显的位置异常撕裂!这个问题开源库中早有issue,你可以看下里面的视频。问题表现像是这样,注意仔细看右侧的scrollbar

事实上,我之前在纯View项目中使用其他开源库也遇到了这个问题,并且已经解决。

简单来说,出现这个问题的原因,在于RecyclerView组件没法得知内部子项的实际总体高度------因为子项是按需加载的,不实际加载经过测量自然没有准确高度------只能得到一个估算高度,随着滑动实际值与估算值误差较大导致scrollbar重定位时,就会产生位置撕裂。

Compose中,LazyGrid的懒加载原理跟RecyclerView大差不差,所以会有类似的问题。

在纯View项目中,我并没有去修改开源库的代码,而是从源头上,自定义GridLayoutManager,提供更为精确的测量方法:

kotlin 复制代码
/**
 * 让快速滑动条能够在更准确的位置显示,并且不会产生莫名"回退"的bug
 * */
class FSVertGridLayoutManager(context: Context, spanCount: Int) :
    GridLayoutManager(context, spanCount) {

    override fun computeVerticalScrollOffset(state: RecyclerView.State): Int {
        if (childCount == 0 || state.itemCount == 0) {
            return 0
        }
        val firstPos = findFirstVisibleItemPosition()
        if (firstPos > 0) {
//            位置编号从0开始,因此firstPos / spanCount正确得到当前填满的行数
            val rowCount = firstPos / spanCount
            val childViewHeight = computeChildViewHeight(firstPos)
            return rowCount * childViewHeight + firstChildViewScrollOffset(firstPos)
        } else {
            return firstChildViewScrollOffset(firstPos)
        }
    }

    override fun computeVerticalScrollRange(state: RecyclerView.State): Int {
        if (childCount == 0 || state.itemCount == 0) {
            return 0
        }
        val total = state.itemCount
        val rowCount =
            if (0 == total % spanCount) total / spanCount
            else total / spanCount + 1
        val firstPos = findFirstVisibleItemPosition()
        val childViewHeight = computeChildViewHeight(firstPos)
//        myLogE(TAG, "--- rowCount:$rowCount childViewHeight:$childViewHeight")
        return rowCount * childViewHeight
    }

    private fun computeChildViewHeight(pos: Int): Int {
        val childView = findViewByPosition(pos)
        if (null != childView)
            return childView.height + childView.marginTop + childView.marginBottom
        else
            throw IllegalStateException("pos:$pos")
    }

    private fun firstChildViewScrollOffset(pos: Int): Int {
        val childView = findViewByPosition(pos)
        if (null != childView)
            return childView.marginTop - childView.top
        else
            throw IllegalStateException("pos:$pos")
    }

    companion object {
        const val TAG = "FSVertGridLayoutManager"
    }
}

现在,在Compose组件LazyColumnScrollbar中,由于是基于state获取信息,情况会稍有不同。经过一段代码追踪,很容易发现确定scrollbar位置的代码由LazyGridStateControllerthumbOffsetNormalized属性确定。我们看下相关实现:

kotlin 复制代码
val thumbOffsetNormalized = remember {
    derivedStateOf {
        state.layoutInfo.let {
            if (it.totalItemsCount == 0 || it.visibleItemsInfo.isEmpty())
                return@let 0f

            val firstItem = realFirstVisibleItem.value ?: return@let 0f
            val top = firstItem.run {
                ceil(index.toFloat() / nElementsMainAxis.value.toFloat()) + fractionHiddenTop(
                    state.firstVisibleItemScrollOffset
                )
            } / ceil(it.totalItemsCount.toFloat() / nElementsMainAxis.value.toFloat())
            offsetCorrection(top)
        }
    }
}

粗看上去,这里分子分母同时使用ceil令人感到不安:

kotlin 复制代码
val top = firstItem.run { 
ceil(index.toFloat() / nElementsMainAxis.value.toFloat()) + fractionHiddenTop( state.firstVisibleItemScrollOffset ) }
/ ceil(it.totalItemsCount.toFloat() / nElementsMainAxis.value.toFloat())

不过后面还使用了一个offsetCorrection(top),也可能工作机制没问题。

不过,当我们进一步追踪fractionHiddenTop

kotlin 复制代码
fun LazyGridItemInfo.fractionHiddenTop(firstItemOffset: Int): Float {
    return when (orientationUpdated.value) {
        Orientation.Vertical -> if (size.height == 0) 0f else firstItemOffset / size.width.toFloat()
        Orientation.Horizontal -> if (size.width == 0) 0f else firstItemOffset / size.width.toFloat()
    }
}

这就尴尬了,Orientation.Vertical -> if (size.height == 0) 0f else firstItemOffset / size.width.toFloat()明显不符合逻辑:(

做如下修改后,在我的使用场景中scrollbar位置撕裂问题得到了有效修正,顺手给官方提了个PR:)

kotlin 复制代码
fun LazyGridItemInfo.fractionHiddenTop(firstItemOffset: Int): Float {
    return when (orientationUpdated.value) {
        Orientation.Vertical -> if (size.height == 0) 0f else firstItemOffset / size.height.toFloat()
        Orientation.Horizontal -> if (size.width == 0) 0f else firstItemOffset / size.width.toFloat()
    }
}

由于已经达到预期效果,我没有更进一步分析是否存在其他问题,感兴趣的读者可自行研究。

下一节,我们将尝试加入书签功能。

相关推荐
码哥字节1 小时前
9 个开源 App,治好了我的 vibe coding 焦虑
ai编程·vibecoding
happyfire1 天前
Vibe Coding 一年,我发现 AI 写代码其实是最简单的部分
vibecoding
桦说编程2 天前
【AtomicAgent系列1】变异测试——过去做不起,现在 agent 做得起
后端·ai编程·vibecoding
ServBay2 天前
谷歌发布 Gemini 3.8 Flash,官方跑分很厉害,但又被嘲了?
aigc·ai编程·gemini
deepseek232 天前
Google Gemini Agentic Video 上线:88% 少 token 的主动取样如何改写长视频理解
python·多模态·ai agent·gemini·视频理解
Behavior7 天前
刚刚,Claude 5.1 发布!全球最强模型来了?
aigc·claude·vibecoding
一用书生7 天前
我给 ChatGPT、DeepSeek、Kimi 都加了一个「保存为笔记」按钮
前端·ai编程·vibecoding
爱丶不疚8 天前
Eval: Agent 说的 Eval 是什么?从单测、TDD 到 Sentry 聊起
前端·ai编程·vibecoding
夏天要喝冰可乐8 天前
从 Idea 到开源插件:我用 Vibe Coding 做了「文章摆渡」
前端·ai编程·vibecoding
Bigger9 天前
别再拿大炮打蚊子了,我给 Codex 加了一个自动驾驶
人工智能·openai·vibecoding