20260526
读者应具备的知识基础
Kotlin、 Compose、 基础Android开发(谷歌官网课程优先)
尝试Vibe coding修复水平翻页下的Bug
我们切换到计划模式,把观察到的异常现象告诉AI。同时给出AI选择,不要没法改还硬改。

AI给出了如下回答:


非常遗憾的是,应用这个修改,我们测试后发现仍然有问题:

再追问几次,问题仍旧没有解决:(
这说明这是现阶段会把AI难住的问题。出错的情况千奇百怪,这里我不再列明。
到这时,迫于无奈,轮到我们自己出手看下它代码了。根据我的经验之谈,你最好回退到AI第一次出错的节点进行处理,这时候情况往往最为简单,而不会引入过多"杂质"。
它具体实现的Compose结构由外到内为:
Box(触控监听层)->HorizontalPager->Box(触控响应层)->Image
结构十分简单,最外层Box捕捉所有触控事件,HorizontalPager提供水平分页的能力,而里层Box基于参数来变形,省略代码后如下图:
kotlin
Box(){
HorizontalPager(){
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
// 根据参数变形
},
contentAlignment = Alignment.Center
) {
Image(){
//显示图片
}
}
}
}
经过实验,我观察到只要页面有放大,HorizontalPager这个组件就不再能够正常翻到其他页。因此,有可能从技术选型的角度上,这个Bug就无法处理。如果这样的前提是正确的话,由于Gemini一般情况下并没有走回头路推翻旧有设计的能力,那它在错误上一路狂奔自然也就跑不出好结果。
不过,如果你用过足够多的漫画app,应该会发现水平翻页模式下,别家app是可以在页面放大的情况下正常翻到下一页的。比如,mihon就可以,我们直接让AI分析一下它的解决方案。

分析源码未必是一件简单的事,我这里Gemini直接卡死无输出了:(

还记得我之前说过,网页版Gemini可以限额使用Pro模型吗?我们也问下它看:

回答如下:

很遗憾,mihon是基于View体系解决的问题,我们是纯Compose,没作业可抄。
进行Vibe Coding,遭遇挫败是十分常见的事。因此我们先跳过这一节,先让AI把代码限定为:水平翻页模式下,放大后不能换页。
难道就这样放弃了吗?当然不是的!请容我先挖个坑,这个问题涉及到了其他方面的功能,并非一时能够说清与搞定,我们将在今后的篇章详细讨论这个问题。
基于范例Vibe coding
由于实现原理的关系,Vibe coding的输出结果往往不可控,不仅仅是提示词的差异会让结果有差异,即便是完全一致的提示词,在不同的时空变量下,仍旧可能产出具有微妙差异的结果。
为了让输出结果尽可能可控,发明出了Skill,即只要检测到特定提示词,就执行特定工序,外挂更多的引用和参数。
而对于我们现在这个具备个性化的特定小众软件开发来说,我们提升Vibe coding输出结果确定性的方案是直接给出范例。这样,让AI照抄执行的效果会变得自然可控。
比如,我们的软件需要设置页面,我们简单设计并完成了主设置页,效果如下:

这个SettingsScreen中的各项功能,其实现逻辑、作用对象很多不具备关联,因此主要还是人工实现。
在外观上,我们抽象出了四种不同外观的设置子项Compose函数,它们大同小异,不过是圆角处理有所不同,以下省略掉了MiddleSettingCard和BottomSettingCard:
kotlin
@Composable
fun SettingCard(
modifier: Modifier = Modifier,
onClick: () -> Unit,
backgroundColor: Color? = null,
rowContent: @Composable () -> Unit
) {
Card(
onClick = onClick,
modifier = modifier,
shape = RoundedCornerShape(
Dimen.settingCardRoundedCornerSizeBig,
),
colors =
if (backgroundColor != null)
CardDefaults.cardColors(
containerColor = backgroundColor
)
else
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
// CardDefaults.cardColors()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
top = Dimen.settingCardContentTopBottomPadding,
bottom = Dimen.settingCardContentTopBottomPadding,
start = Dimen.settingCardContentLeftRightPadding,
end = Dimen.settingCardContentLeftRightPadding,
),
verticalAlignment = Alignment.CenterVertically
) {
rowContent()
}
}
}
@Composable
fun TopSettingCard(
modifier: Modifier = Modifier,
onClick: () -> Unit,
backgroundColor: Color? = null,
rowContent: @Composable () -> Unit
) {
Card(
onClick = onClick,
modifier = modifier,
shape = RoundedCornerShape(
topStart = Dimen.settingCardRoundedCornerSizeBig,
topEnd = Dimen.settingCardRoundedCornerSizeBig,
bottomStart = Dimen.settingCardRoundedCornerSizeSmall,
bottomEnd = Dimen.settingCardRoundedCornerSizeSmall
),
colors =
if (backgroundColor != null)
CardDefaults.cardColors(
containerColor = backgroundColor
)
else
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
// CardDefaults.cardColors()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
top = Dimen.settingCardContentTopBottomPadding,
bottom = Dimen.settingCardContentTopBottomPadding,
start = Dimen.settingCardContentLeftRightPadding,
end = Dimen.settingCardContentLeftRightPadding,
),
verticalAlignment = Alignment.CenterVertically
) {
rowContent()
}
}
}
@Composable
fun SettingCardGapSpacer() {
Spacer(modifier = Modifier.height(Dimen.settingCardGapSpacerHeight))
}
我们的逻辑体系足够简单,设置页本身不过是Screen,ViewModel,Repository三个类协同工作。相信阅读过Google基础课程的你能够轻易理解。Google官网的原理图大概如下:

我们没有中间的Domain层,ViewModel依赖Repository作为构造参数,暴露出StateFlow,Screen经由观察者模式自动响应StateFlow变化而刷新UI。第一次接触这种声明式UI的思想时,我简直惊了个呆,心想我从前那么多代码手动控制UI状态简直没有任何意义!
这一整套基于最佳实践的做法堪称模版代码,在此不做赘述。如果你需要观看细节,请查看Google基础课程。
我们的App中的设置项,有些是仅针对浏览器界面才有用,因此不妨专门设计一个BrowserSettingsScreen,在阅读界面按下按钮时进入。

可以看到不只是这个按钮,下方我还放了四个按钮,分别是两个翻页按钮和两个跳页按钮。
好了,素材准备完毕,接下来就是Vibe coding时间,相信不用我多说你也明白了,通过@指定Screen,ViewModel,Repository三个文件,然后跟AI简单描述清楚:
生成一个BrowserSettingsScreen,并实现相关功能,第1类是"视图",第一个子设置项是"阅读模式",该项分为三个选项"水平翻页"、"水平连续"、"垂直连续"......
其余的提示词也诸如此类,总之,经过一波用嘴编程,AI最终完美做好了这个BrowserSettingsScreen,并实现了其中的每一个功能!

可以看到,除开Compose函数它直接复用保证了外观一致性以外,其余的功能实现它仅仅参照我们之前的架构模式就轻易完成了。
添加数据库,完成阅读进度功能
我们的应用需要能保存漫画阅读进度,这样才能提供连贯的阅读体验,我们使用数据库来实现这个功能。
不应假手于AI的领域
数据库基础设计是我强烈推荐不使用AI,由你亲自编码的领域之一。
你应当熟知你的App保存哪些数据,预留扩展字段,哪些功能可以据此开发。特别是通过Room也写不了几行代码,你不应该偷这个懒。
举个例子,我们设计这样一个阅读进度实体ReadingRecord:
kotlin
@Entity
data class ReadingRecord(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val path: String,
val currentPageIndex: Int,
val maxPage: Int,
val pieceNumber: Int,
val pieceCount: Int,
val progressFlag: Int,
)
path是书籍的完整路径,currentPageIndex是当前阅读到的页码索引,maxPage是最大页码。
pieceNumber是碎片编号,pieceCount是碎片数量,这两个是预留的扩展字段,对于阅读条漫时,处理超长图我们需要对图片进行分片加载,这两个字段指示当前阅读的超长图位置。
progressFlag是阅读进度标志,分"未读""在读""已读"三种。也留待今后实现。
Dao和Database的实现都是模版代码,在此不作赘述。
素材准备好了,我们试着发出指令让AI生成Repository:
我添加好了数据库类,你做个DatabaseRepository,放到 repository目录下
由于是完完全全的模版代码,DatabaseRepository看上去像是没什么问题:
kotlin
class DatabaseRepository(
private val libraryDao: LibraryDao,
private val bookmarkDao: BookmarkDao,
private val readingRecordDao: ReadingRecordDao
) {
// Library
suspend fun insertLibrary(library: Library) = withContext(Dispatchers.IO) {
libraryDao.insert(library)
}
suspend fun deleteLibrary(library: Library) = withContext(Dispatchers.IO) {
libraryDao.delete(library)
}
suspend fun searchLibraryByPath(path: String) = withContext(Dispatchers.IO) {
libraryDao.searchByPath(path)
}
suspend fun loadAllLibraries() = withContext(Dispatchers.IO) {
libraryDao.loadAll()
}
// Bookmark
suspend fun insertBookmark(bookmark: Bookmark) = withContext(Dispatchers.IO) {
bookmarkDao.insertBookmark(bookmark)
}
suspend fun deleteBookmark(bookmark: Bookmark) = withContext(Dispatchers.IO) {
bookmarkDao.deleteBookmark(bookmark)
}
suspend fun updateBookmark(bookmark: Bookmark) = withContext(Dispatchers.IO) {
bookmarkDao.updateBookmark(bookmark)
}
suspend fun queryBookmarksByKeyPath(keyPath: String) = withContext(Dispatchers.IO) {
bookmarkDao.queryBookmarksByKeyPath(keyPath)
}
suspend fun deleteBookmarksByKeyPath(keyPath: String) = withContext(Dispatchers.IO) {
bookmarkDao.deleteBookmarksByKeyPath(keyPath)
}
suspend fun searchBookmarkByKeyPathAndPage(keyPath: String, page: Int) = withContext(Dispatchers.IO) {
bookmarkDao.searchBookmarkByKeyPathAndPage(keyPath, page)
}
suspend fun loadAllBookmarks() = withContext(Dispatchers.IO) {
bookmarkDao.loadAllBookmarks()
}
// ReadingRecord
suspend fun insertReadingRecord(readingRecord: ReadingRecord) = withContext(Dispatchers.IO) {
readingRecordDao.insertReadingRecord(readingRecord)
}
suspend fun deleteReadingRecord(readingRecord: ReadingRecord) = withContext(Dispatchers.IO) {
readingRecordDao.deleteReadingRecord(readingRecord)
}
suspend fun updateReadingRecord(readingRecord: ReadingRecord) = withContext(Dispatchers.IO) {
readingRecordDao.updateReadingRecord(readingRecord)
}
suspend fun searchReadingRecordByKeyPath(keyPath: String) = withContext(Dispatchers.IO) {
readingRecordDao.searchReadingRecordByKeyPath(keyPath)
}
suspend fun loadAllReadingRecords() = withContext(Dispatchers.IO) {
readingRecordDao.loadAllReadingRecords()
}
suspend fun loadReadingRecordsByPrefix(prefix: String) = withContext(Dispatchers.IO) {
readingRecordDao.loadReadingRecordsByPrefix(prefix)
}
}
备菜完毕,看下AI主厨能不能端碗好菜上来:
基于 @DatabaseRepository.kt ,在 @BrowserScreen.kt 中加入阅读进度功能

以下是运行效果:

表面上似乎很好,但这涉及数据库,我建议你慎重对待。这属于原则中的例外,你应当复查操作数据库的代码。
修正AI的代码
我们复查下它写的代码:
kotlin
fun parseFile() {
_uiState.value = _uiState.value.copy(bootedParsingFile = true)
viewModelScope.launch {
val comicParsingState = if (comicPageRepository.parseFile())
ComicParsingState.SUCCESS
else
ComicParsingState.FAILURE
val maxPageIndexSize = comicPageRepository.imageListMaxSize()
// 在这里读取数据库
val record = databaseRepository.searchReadingRecordByPath(comicPageRepository.filePath)
_uiState.value = _uiState.value.copy(
filePath = comicPageRepository.filePath,
comicParsingState = comicParsingState,
maxPageIndexSize = maxPageIndexSize,
currentPageIndex = record?.currentPageIndex ?: 0 // 应用 阅读进度数据
)
}
}
在我们定义好的parseFile函数中,读取和应用部分粗看上去没有问题。转头再一想,这不是没做越界检查吗? 虽然是小概率事件,但如果出现,书籍将无法打开,这显然不可接受。 唉,手动加上吧。
kotlin
fun parseFile() {
_uiState.value = _uiState.value.copy(bootedParsingFile = true)
viewModelScope.launch {
val comicParsingState = if (comicPageRepository.parseFile())
ComicParsingState.SUCCESS
else
ComicParsingState.FAILURE
val maxPageIndexSize = comicPageRepository.imageListMaxSize()
val record = databaseRepository.searchReadingRecordByPath(comicPageRepository.filePath)
val currentPageIndex = record?.currentPageIndex ?: 0
_uiState.value = _uiState.value.copy(
filePath = comicPageRepository.filePath,
comicParsingState = comicParsingState,
maxPageIndexSize = maxPageIndexSize,
currentPageIndex =
if (currentPageIndex < maxPageIndexSize) // 人工补充越界检查
currentPageIndex
else
0
)
}
}
再来看下它怎么保存的:
kotlin
private fun saveReadingRecord(index: Int) {
viewModelScope.launch {
val path = _uiState.value.filePath
val maxPage = _uiState.value.maxPageIndexSize
val record = databaseRepository.searchReadingRecordByPath(path)
if (record == null) {
databaseRepository.insertReadingRecord(
ReadingRecord(
path = path,
currentPageIndex = index,
maxPage = maxPage,
pieceNumber = 0,
pieceCount = 0,
progressFlag = ProgressFlag.READING.value
)
)
} else {
databaseRepository.updateReadingRecord(
record.copy(
currentPageIndex = index,
maxPage = maxPage,
progressFlag = if (index >= maxPage - 1) ProgressFlag.READ.value else ProgressFlag.READING.value
)
)
}
}
}
首先查找数据库中是否已有阅读记录,若无则插入一条新数据,反之有则更新旧数据。整体逻辑没有问题,但我们仍需看下它在哪里调用,先是BrowserViewModel中的updateCurrentPageIndex:
kotlin
fun updateCurrentPageIndex(index: Int) {
if (index != _uiState.value.currentPageIndex) {
_uiState.value = _uiState.value.copy(currentPageIndex = index)
saveReadingRecord(index)
}
}
可以看到,_uiState.value = _uiState.value.copy(currentPageIndex = index)是更新UI页码,而saveReadingRecord(index)是将页码更新到数据库。
在BrowserScreen中,updateCurrentPageIndex会给到PagesLayout,作为onPageSelected参数:
kotlin
PagesLayout(
settingsUiState = settingsUiState,
uiState = uiState,
onToggleMenu = { viewModel.toggleMenu() },
onPageSelected = { viewModel.updateCurrentPageIndex(it) },
onShowMessage = showMessage,
goBack = goBack,
goToSettings = goToSettings
) { viewModel.getComicPage(it) }
在PagesLayout中,updateCurrentPageIndex被分给不同的布局(比如水平翻页布局),以及分给用于跳转页面的Slider。总之,我们可以看出,只要出现页面变化,立即就会更新数据库。
对于我们的App来说,翻页是最频繁的动作,而频繁更新数据库显然不合适。
在View体系中,保存数据的节点在Activityd的onPause方法。而在Compose体系中,我们监听生命周期做类似处理。因此,从updateCurrentPageIndex中把saveReadingRecord(index)摘取出来,做一个Effect操作。同时,再结合让AI自行检查代码,最终做如下修改:
BrowserScreen中:
kotlin
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, evevnt ->
if (Lifecycle.Event.ON_PAUSE == evevnt) {
viewModel.saveReadingRecord()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
BrowserViewModel中:
kotlin
fun updateCurrentPageIndex(index: Int) {
if (index != _uiState.value.currentPageIndex) {
_uiState.value = _uiState.value.copy(currentPageIndex = index)
}
}
fun saveReadingRecord() {
saveReadingRecord(uiState.value.currentPageIndex)
}
private fun saveReadingRecord(index: Int) {
viewModelScope.launch {
val path = _uiState.value.filePath
if ("" == path) return@launch
val maxPage = _uiState.value.maxPageIndexSize
val record = databaseRepository.searchReadingRecordByPath(path)
val isFinished = index >= maxPage - 1 && maxPage > 0
val flag = if (isFinished) ProgressFlag.READ.value else ProgressFlag.READING.value
if (record == null) {
databaseRepository.insertReadingRecord(
ReadingRecord(
path = path,
currentPageIndex = index,
maxPage = maxPage,
pieceNumber = 0,
pieceCount = 0,
progressFlag = flag
)
)
} else {
databaseRepository.updateReadingRecord(
record.copy(
currentPageIndex = index,
maxPage = maxPage,
progressFlag = flag
)
)
}
}
}
经过这次,你应当明白AI代码的可靠性并非100%值得信赖了。对于生产环境会有重大影响的部分,你必须慎之又慎。
下一节,我们将尝试添加更多功能并调整UI。