20260524
读者应具备的知识基础
Kotlin、 Compose、 基础Android开发(谷歌官网课程优先)
准备项目基础
在我的实践中,纯粹由AI从零开始 Vibe coding并不合适,非常容易出现各种问题。再加上标准的Android应用开发,本身已经存在最佳实践。因此我们换一种方法,就像造房子,我们先自己打好地基,再让AI往上施工。
新建项目略去不表。我们来看技术架构,遵照谷歌推荐的做法,我们先做简单规划:
-
目标平台Android 16,要上架Play商店这是硬性要求。
-
最低支持 Android 11,本漫画软件需要实现对书籍文件的编辑管理,也就是依赖所有文件管理权限 android.permission.MANAGE_EXTERNAL_STORAGE,11以下旧式的权限会在某些场景产生问题。
-
单Activity应用
-
纯Compose,使用bom管理相关依赖库,不去追求实验性UI
-
分UI层,Data层两层。保证单向数据流。
-
使用容器管理依赖项(简单情况一个文件就好,并不当然需要Hilt)
-
SplashScreen搞定开屏页
-
navigation3导航页面切换
-
分离出mangasupport模块。我们的app有两个版本免费版、付费版,显然不可能许多代码写两遍,因此我们把通用业务逻辑放到这里
-
开源库依赖若干。比如,为支持Zip漫画支持,我们选用zip4j。注意,这个库并未考虑多线程调用的情况,我们将做两个小修改解决问题。
配置navigation3
navigation2是基于Fragment的,而我们的项目目标是纯Compose,因此最好用上navigation3,特别是Screen间的传值,navigation3要好得多,对于返回栈的自由管理也方便得多。Google的nagation3教程写得比较随意。我推荐你观看扔物线的视频,有个基础概念,再按教程进行配置。
但是,在实操这里,有关NavigationState和Navigator你可能用不到它那种三个Tab页,各自独立一套回退栈的情况。这时,你可以参考我提供的简单回退栈(含附加功能)实现:
NavigationState,开启了rememberViewModelStoreNavEntryDecorator(),每个ViewModel将与对应页面绑定生命周期。
kotlin
@Composable
fun rememberNavigationState(
startRoute: NavKey,
): NavigationState {
val backStack = rememberNavBackStack(startRoute)
return remember(backStack) {
NavigationState(
backStack = backStack
)
}
}
class NavigationState(
val backStack: NavBackStack<NavKey>
) {
}
/**
* Convert NavigationState into NavEntries.
*/
@Composable
fun NavigationState.toEntries(
entryProvider: (NavKey) -> NavEntry<NavKey>
): SnapshotStateList<NavEntry<NavKey>> {
val decorators = listOf(
rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
// ✨ 关键:加入这个装饰器!它会为每一个入栈的页面分配独立的 ViewModelStore
rememberViewModelStoreNavEntryDecorator()
)
val l = rememberDecoratedNavEntries(
backStack = backStack,
entryDecorators = decorators,
entryProvider = entryProvider
)
return l.toMutableStateList()
}
Navigator,加入了防抖功能
kotlin
class Navigator(val state: NavigationState, val finishApp: () -> Unit) {
var navigateLastTime = 0L
var goBackLastTime = 0L
val backStack = state.backStack
fun navigate(route: NavKey): Boolean {
val now = System.currentTimeMillis()
val tmpLastTime = navigateLastTime
navigateLastTime = now
if (now - tmpLastTime > DEBOUNCE_TIME) {
backStack.add(route)
return true
}
return false
}
fun navigateToBrowser(initialFilePath: String) {
val now = System.currentTimeMillis()
val tmpLastTime = navigateLastTime
navigateLastTime = now
if (now - tmpLastTime > DEBOUNCE_TIME) {
backStack.add(Browser(initialFilePath))
}
}
fun navigateAndClear(route: NavKey) {
if (navigate(route))
for (i in backStack.size - 2 downTo 0)
backStack.removeAt(i)
}
fun goBack() {
val now = System.currentTimeMillis()
val tmpLastTime = goBackLastTime
goBackLastTime = now
if (now - tmpLastTime > DEBOUNCE_TIME) {
if (backStack.size > 1) {
backStack.removeLastOrNull()
} else {
finishApp()
}
}
}
companion object {
const val DEBOUNCE_TIME = 300L
}
}
让zip4j库实现多线程安全
zip4j库本身未提供多线程模式。在它的使用上,我实测它创建ZipFile的花销很大,因此一经创建,我们就利用ZipFile实例来做多线程解压,这样才能具有流畅阅读的体验。但是,zip4j库在多线程解压时会抛出错误造成解压失败。
经过一段时间的bug追踪,我定位并分析出了解决多线程安全的改法。由于这仅仅是小众软件中的小众需求,我没有向官方提交代码。具体改法如下:
1、ZipFile类第105行,openInputStreams改为并发容器

2、AbstractExtractFileTask,修改extractFile方法内65行以下代码:

kotlin
boolean errorOccurred = false;
try {
checkOutputDirectoryStructure(outputFile);
} catch (Exception e) {
errorOccurred = true;
}
// 目前这个补丁似乎工作得很好 :)
if (errorOccurred) {
if (outputFile.getParentFile().exists())
unzipFile(zipInputStream, outputFile, progressMonitor, readBuff);
} else {
unzipFile(zipInputStream, outputFile, progressMonitor, readBuff);
}
// Original code:
// checkOutputDirectoryStructure(outputFile);
// unzipFile(zipInputStream, outputFile, progressMonitor, readBuff);
Vibe coding的原则与能力边界
对话模式
Gemini提供三种对话模式:问答,快速,计划。

这三种模式的推理能力逐级上升。问答模式我一般不使用,因为往往都涉及到任务实施,即便不需要改代码,我都是在快速模式中,给出提示词"先别改代码,你说说看......"。计划模式耗时会比较久一点,用于实施前进行深度思考。
我建议仅对明显有难度、或Gemini明显处理有问题的部分采用这种模式,否则一律采用快速模式。这是因为这种模式最适合快问快答,加速代码生成与演化。
原则
原则一:采用迭代开发。
实现具有难度的功能,我的建议是采用迭代开发思维,先让AI完成一个 基本实现->测试实现是否完成->Git保存代码->然后追加功能->再测试......走这样一个循环。即便最终AI无法完成该功能,你也会得到一个有所保障的半成品。
据我观察,AI写出的代码,有时候尚可,有时又会非常诡异,比如可能有以下问题:
- 莫名定义了变量,但到后面有的地方用一下,有的地方又不用。
- 本应当相当简单就能实现,它却花费了大量代码
- 有些经典公式它不懂得使用,于是得不到性能最优解。一个典型例子是a/b的向上取整公式, (a+b-1)/b。又比如某些大数运算下防止溢出需要先减后加或是先除后乘,AI完全没有这样的自觉
- 某些代码片段某些情况下存在Bug。可能是没有有效性检查
- 代码其实是一坨Shit,但刚好能跑且跑出了你要的结果
原则二:如非必要,不要费神去阅读AI写的代码。
特别是如果你也像我一样有长年编码经验,有优质代码洁癖的话,AI写的代码会简直没眼看。你应当以结果为导向,把它的产出视作黑盒处理。
原则三:认清AI代码可能存在的问题如前所述,AI代码往往不像优秀工程师那样可以产出最优解。需要警惕的是,性能最不最优姑且可以放到一边,但微小错误的累积有可能造成灭顶之灾。你应当在软件架构上做出良好的设计分层,这样的话发生火灾也不会蔓延。
AI写代码并不如人类谨慎。特别是已经开发完成、验收无误的代码,如果它开发新功能时视作有关联,它可能会一并修改,从而直接引入不必要的新Bug。
原则四:警惕退化
如果你十分清楚修改范围,最好使用 @ 符号或者语言描述限制AI的自由发挥。
能力边界
AI显然存在能力边界,在那些领域,你应当做好自己上手的准备,以下是Gemini的回答:


虽然它是这样说,但我体验时许多稍微复杂的功能它也无法实现:(当然这不是它的错,所有AI半斤八两,关于这一点你可自行验证。
Vibe coding示例:实现水平翻页功能
水平翻页功能
在新的架构下,基于旧有代码,我们很快构建起了App原型。

我们创建了一个BrowserScreen专门用于阅读,提供三种阅读模式:水平翻页,水平连续、垂直连续。
漫画阅读最基本的实现就是占满整个屏幕的一页,水平方向左右翻页,类同于纸质书籍的阅读体验。
其中,显然水平连续、垂直连续使用LazyRow/LazyColumn即可简单实现,这直接对应View体系中的RecyclerView,我的旧项目中有现成代码随便改两行即告完成。水平翻页就不一样了,我采用了自定义类实现,并且通过定义接口,提供多种不同翻页效果的实现类。而在Compose体系下,这显然无法直接对应,并且由于实现路径的差异,也并不是很好直接移植。
很好,我们把这个任务丢给AI。在计划模式下提问:

小技巧:主键盘区回车键发送对话,而小键盘区回车键是换行。
AI回答如下:


看上去挺像那么一回事,好吧,实际上我没怎么看:) 希望你没有忘记原则二,如非必要我不会去读AI写的代码,当然也不会让你受苦。
我们让AI实施,然后直接运行检查下效果。

看上去一切都很美好,直到最后,放大后的图片再移动时出现了黑块:(
下一期,我们将尝试修复这个bug。