从零开始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()
    }
}

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

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

相关推荐
csdn_aspnet13 小时前
Gemini赋能安全工程师,自动写PoC脚本,探索Gemini在网络安全领域辅助漏洞验证与POC生成的实战路径
安全·web安全·prompt·poc·gemini·工程师
小小小小小鹿17 小时前
Vibe Coding 实战:Flutter 自定义路径布局
flutter·vibecoding
Captaincc17 小时前
近期感想,VibeCoding会放大内心的ego 非必要,坚决不造轮子。
vibecoding
Resistance丶未来1 天前
魔芋AI:构建安全、可控、合规的大模型生产力枢纽
gpt·安全·大模型·claude·gemini·企业ai·魔芋ai
小小小小小鹿2 天前
Vibe Coding 全栈实战:章鱼哥解题 07|功能跑通后的架构收敛
ai编程·vibecoding
小小小小小鹿2 天前
Vibe Coding 全栈实战:章鱼哥解题 06|对话持久化与用户数据隔离
ai编程·vibecoding
JavaGuide2 天前
Spec Coding 规范驱动编程实战:从 Vibe Coding 到 AI 代码规范
后端·vibecoding
小小小小小鹿2 天前
Vibe Coding 全栈实战:章鱼哥解题 05|打通前后端鉴权链路
ai编程·vibecoding
小小小小小鹿2 天前
Vibe Coding 全栈实战:章鱼哥解题 04|从后端回答到流式对话界面
ai编程·vibecoding