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

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函数,它们大同小异,不过是圆角处理有所不同,以下省略掉了MiddleSettingCardBottomSettingCard

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作为构造参数,暴露出StateFlowScreen经由观察者模式自动响应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是阅读进度标志,分"未读""在读""已读"三种。也留待今后实现。

DaoDatabase的实现都是模版代码,在此不作赘述。

素材准备好了,我们试着发出指令让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。

相关推荐
小小小小小鹿43 分钟前
Vibe Coding 全栈实战:章鱼哥解题 03|从教材检索到 AI 回答生成
ai编程·vibecoding
AlfredZhao2 小时前
入门:我的第一个Vibe Coding实践程序
ai·codex·vibecoding
腾讯云云开发5 小时前
CloudBase把一套完整的 Vibe Coding 平台开源了
后端·全栈·vibecoding
小小小小小鹿7 小时前
Vibe Coding 全栈实战:章鱼哥解题 02|搭建教材知识库与检索基线
ai编程·vibecoding
星浩AI14 小时前
Anthropic:Claude Code 如何在大型代码库中工作
agent·claude·vibecoding
政采云技术2 天前
从 Vibe Coding 到 SDD:AI 编程的工程化演进
ai编程·vibecoding
小小小小小鹿2 天前
Vibe Coding 全栈实战:章鱼哥解题 01|搭好产品底座与登录链路
vibecoding
yezannnnnn2 天前
Claude code 5 小时额度卡住?多账户错峰激活让你一天平滑使用不断额
人工智能·claude·vibecoding
duanze3 天前
从零开始Android商业项目Vibe coding完全指南(二)
vibecoding