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/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位置的代码由LazyGridStateController的thumbOffsetNormalized属性确定。我们看下相关实现:
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()
}
}
由于已经达到预期效果,我没有更进一步分析是否存在其他问题,感兴趣的读者可自行研究。
下一节,我们将尝试加入书签功能。