20260602
读者应具备的知识基础
Kotlin、 Compose、 基础Android开发(谷歌官网课程优先)
javascript, jquery
书签功能
我们这样设计书签实体:
kotlin
@Entity
data class Bookmark(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val path: String, // 书籍路径
val pageIndex: Int, // 页码索引
val description: String, //可选描述
val addTime: Long // 添加时间
)
其他的Dao都是常规写法不再赘述。
我们已经累积了足够多的素材,因此我们期望彻底的Vibe coding,把以下指令输入给Gemini:
@Bookmark.kt 实现书签功能。 @BrowserScreen.kt 在BottomMenuBar中 BottomMenuIconButton(R.drawable.bookmark_24dp, {}) 添加或删除书签, BottomMenuIconButton(R.drawable.bookmarks_24dp, {}) 显示出书签界面:具体通过BottomSheet实现,在BottomSheet中放个LazyGrid,手机1列,否则2列,里面Item的Card外观参考 @BookshelfScreen.kt 中的FolderListItem,但是图片右侧的文字从上往下显示三行:第一行显示第几页,第二行显示描述(可选值,可能为空),第三行显示添加时间;右边给个删除书签按钮
出来的效果似乎还行:
那么老规矩,我们检查下它的实现。我们发现它在uiState中添加了两个参数isCurrentPageBookmarked,bookmarks:
kotlin
data class BrowserUiState(
val filePath: String = "",
val bootedParsingFile: Boolean = false,
val comicParsingState: ComicParsingState = ComicParsingState.PARSING,
val maxPageIndexSize: Int = 0,
val currentPageIndex: Int = 0,
val showMenu: Boolean = false,
val showFullPageOverview: Boolean = false,
val isCurrentPageBookmarked: Boolean = false,
val bookmarks: List<com.feiwen.mviewerlite.data.db.entity.Bookmark> = emptyList()
)
显然,在已有bookmarks的情况下就可以直接计算出isCurrentPageBookmarked,这是一个多余的参数。我们让AI优化掉它:
已经有bookmarks的情况下显然可以判定isCurrentPageBookmarked,所以这个参数显然多余,你优化下代码去掉它
得到回复如下:
css
代码已优化,去掉了冗余的 isCurrentPageBookmarked 状态:
优化点:
1.
BrowserViewModel:
◦
删除了 BrowserUiState 中的 isCurrentPageBookmarked 字段。
◦
移除了 checkIfCurrentPageIsBookmarked() 方法及其所有调用。
◦
toggleBookmark() 现在直接通过已有的 bookmarks 列表查找当前页是否已存在书签,减少了一次数据库查询。
◦
更新书签后统一通过 refreshBookmarks() 同步列表。
2.
BrowserScreen:
◦
在 BottomMenuBar 的调用处,使用 remember(uiState.currentPageIndex, uiState.bookmarks) 实时计算当前页是否已加书签:
Kotlin
val isCurrentPageBookmarked = remember(uiState.currentPageIndex, uiState.bookmarks) {
uiState.bookmarks.any { it.pageIndex == uiState.currentPageIndex }
}
这样不仅简化了状态管理,还保证了 UI 逻辑与数据源的绝对同步。
对于图片的加载,它复用了上一节全页俯瞰功能中用到的方法,我们之前已经进行过优化,可以暂且不动观察用户反馈。
总体来说,这次Vibe coding比较顺利,不过问题在于,仅仅是这一个功能,Google就提示用量已达限额:(
好吧,接下来我们姑且自己动动手了,等之后用量恢复再薅AI羊毛。
WiFi导入功能
基本情况
这个功能的基本原理,是让手机启动一个局域网服务器(Android中通过Service实现),电脑端连接后再通过http协议传输文件。我们参考开源项目WifiTransfer来实现它。
这个项目所依赖的底层库AndroidAsync,基本没什么文档可供参考,这种情况现阶段堪称AI杀手。虽然AI会有输出,但用得多了你自然懂:AI干得快不代表干得好。至少,对于这种"双重盲盒叠加的情况",我是不敢根据AI的胡言乱语去完成项目。
好在,虽然没有文档,但AndroidAsync写得很规整,只要了解相关知识,对人类工程师来说,随便看看源码应该就懂它意思了。
WifiTransfer已知存在以下问题:
- 不支持文件夹传输
- 文件传输基于XMLHttpRequest、formData,中途出现错误,服务器通知客户端也不会主动中断,而是傻傻等到整个文件传输完
- 单线程传输
- 传输中没有错误中断机制
- 对AndroidAsync的使用基于旧版本2+,3+中改了api
- 没有对文件名长度进行处理
第一个问题很好解决。可能是Web前端的代码没有秘密可言,体感AI在这方面的表现远远好于Android开发。十分轻松地,通过AI添加了文件夹传输功能。js代码没什么可说在此我们不再赘述.
而对于第二、第三、第四个问题,由于我们并非专业的传输App,仅仅是给漫画App外挂一个传书功能,不精益求精的情况下暂且搁置。
第五个就不多说了,代码能跑不更新就不更新:)
我们需要解决下问题六,安卓平台的文件名长度限制为255字节,小于windows,因此可能需要做截断处理,设计一个方法truncateFileName:
kotlin
private fun truncateFileName(fileName: String): String {
if (!isFileNameTooLong(fileName)) return fileName
// 分离文件名和后缀(如 ".jpg")
val dotIndex = fileName.lastIndexOf('.')
val name = if (dotIndex > 0) fileName.substring(0, dotIndex) else fileName
val extension = if (dotIndex > 0) fileName.substring(dotIndex) else ""
// 计算可用字节数(为后缀预留空间)
val reservedBytes = extension.toByteArray(Charsets.UTF_8).size
val maxNameBytes = MAX_FILE_NAME_BYTES - reservedBytes
// 按字节截断(避免截断半个UTF-8字符)
var truncatedName = name
while (truncatedName.toByteArray(Charsets.UTF_8).size > maxNameBytes) {
truncatedName = truncatedName.substring(0, truncatedName.length - 1)
}
return truncatedName + extension
}
通信原理
我们先走一轮上传文件到服务器的逻辑
一、电脑端上传文件
网页端js上传文件代码如下:
js
// 单纯上传文件,上传文件夹需要另外的方法
function uploadFiles(files) {
const uploader = getHtml5Uploader();
if (files.length == 1) {
tryUploadFile(files[0], uploader);
return;
} else {
const totalFiles = files.length;
var actualFiles = 0;
for (var i = 0; i < files.length; ++i) {
if (!checkFileNameInvalid(files[i].name)) {
uploader.add(files[i]);
actualFiles++;
}
}
if (totalFiles != actualFiles) {
var msg = STRINGS.YOU_CHOOSE + totalFiles + STRINGS.CHOSEN_FILE_COUNT + actualFiles + STRINGS.VALID_CHOSEN_FILE_COUNT;
alert(msg);
}
}
}
function tryUploadFile(file, uploader) {
var msg = checkFileNameInvalid(file.name);
if (msg) {
alert(msg);
return;
}
uploader.add(file);
}
checkFileNameInvalid用于检测是否不受支持的格式,若是则弹出提示不上传。
具体上传由uploader实现,而它是基于upload5库,重写几个回调方法定义行为来完成我们的功能:
js
// 获取bitcandies.FileUploader,算是个单例
function getHtml5Uploader() {
if (!html5Uploader) {
html5Uploader = new bitcandies.FileUploader({
url: 'upload_files',
maxconnections: 1,
enqueued: function (item) {
var fileName = getFileNameOrFullPath(item)
console.log('getHtml5Uploader enqueued: ' + fileName);
// 保存到items中,将来可以终止上传
uploadingItems[fileName] = item;
var fileSize = item.getSize();
insertEnqueuedItem(fileName, fileSize);
},
progress: function (item, loaded, total) {
var progress = loaded / total;
var progressPercent = Math.round(progress * 100) + "%"
updateUploadingState(item, progressPercent);
},
success: function (item) {
var fileName = item.getFilename();
delete uploadingItems[fileName];
updateUploadingState(item, STRINGS.STATE_UPLOADED);
},
error: function (item, xhr) {
if (512 == xhr.status) {
var existFile = updateUploadingState(item, STRINGS.STATE_ERROR_DUPLICATION);
clearAttrFilename(existFile);
} else {
var existFile = updateUploadingState(item, STRINGS.STATE_ERROR);
clearAttrFilename(existFile);
}
},
aborted: function (item) {
var existFile = updateUploadingState(item, STRINGS.STATE_CANCELED);
clearAttrFilename(existFile);
}
});
}
return html5Uploader;
}
而对于upload5,具体上传方法是doUpload,可以看到原理是通过XMLHttpRequest传输FormData:
js
doUpload: function (item) {
var self = this,
xhr = new XMLHttpRequest(),
formData = new FormData();
item.status = bitcandies.FileUploader.Statuses.UPLOADING;
item.xhr = xhr;
this.options.start.call(this, item, xhr);
xhr.onreadystatechange = function () {
if (xhr.readyState == 4) {
self.onComplete(item, xhr);
}
};
xhr.upload.onprogress = function (e) {
if (e.lengthComputable) {
self.options.progress.call(self, item, e.loaded, e.total, xhr);
}
};
xhr.open(this.options.method, this.options.url, true);
for (var key in item.params) {
formData.append(key, item.params[key]);
}
const file = item.file;
// 似乎是因为AndroidAsync那边无法正确解析中文文件名,这里直接先单独发送过去,并作为开始的标志
// 检查是否是通过文件夹添加的文件
var fileName;
if (file.fullPath) {
fileName = file.fullPath;
} else {
fileName = file.name;
}
console.log('doUpload fileName ' + fileName);
formData.append("fileName", encodeURI(fileName));
formData.append("fileSize", file.size);
formData.append(this.options.fieldname, item.file);
xhr.send(formData);
},
在xhr.open(this.options.method, this.options.url, true);配置了相对url "upload_files",基于服务器地址补全后,我们在formData中先装入文件名,再是文件大小,最后是文件本体,在我们的服务器Service中通过AndroidAsync库处理接收逻辑时,也会基于数据装入的顺序。
二、服务器(应用端)接收文件
以下是Service中服务器监听upload_filesurl的实现,同样地,我们是在实现回调方法:
kotlin
//upload
server.post("/upload_files") { request: AsyncHttpServerRequest, response: AsyncHttpServerResponse ->
val body = request.body as MultipartFormDataBody
val fileUploadHolder = getFileUploadHolder()
/*TODO 先做成私有格式缓存,成功后再改后缀名会比较好*/
body.multipartCallback = MultipartCallback { part: Part ->
if (part.isFile) {
body.dataCallback =
DataCallback { emitter: DataEmitter?, bb: ByteBufferList ->
fileUploadHolder.write(bb.allByteArray)
bb.recycle()
}
} else {
val partName = part.name
logE(TAG, "partName:$partName")
// 实际上这里的代码运行在先,先基于文件名设置了文件输出流
if ("fileName" == partName) {
body.dataCallback =
DataCallback { emitter: DataEmitter?, bb: ByteBufferList ->
try {
val fileName = URLDecoder.decode(
String(bb.allByteArray),
"UTF-8"
)
fileUploadHolder.setFileName(fileName)
notifyImportBegin(fileName)
} catch (e: IllegalArgumentException) {
// fileUploadHolder.setFileName(fileName) 可能会由于已存在同名文件而抛出异常
// 使用自定义状态码512通知前端
logEError(TAG, e)
response.code(512).end()
} catch (e: IllegalStateException) {
// 导入的是带目录的文件,在尝试创建父目录时出现错误
logEError(TAG, e)
response.code(500).end()
} catch (e: Exception) {
logEError(TAG, e)
response.code(500).end()
}
bb.recycle()
}
} else if ("fileSize" == partName) {
// 设置文件总长度
body.dataCallback =
DataCallback { emitter: DataEmitter?, bb: ByteBufferList ->
try {
val size = String(bb.allByteArray).toLong()
fileUploadHolder.trySetTotalSize(size)
} catch (e: Exception) {
logEError(TAG, e)
}
bb.recycle()
}
}
}
}
request.endCallback = CompletedCallback { e: Exception? ->
// 不论传输成功,失败,最后总会调用到这里
completeImportThenNotify(fileUploadHolder)
response.end()
logE(TAG, "fileUploadHolder.reset()")
}
}
我们在传输开始、结束、出现错误时,均通过更新SharedFlow来让UI响应刷新。
取消传输功能
我们添加一个取消按钮,以便用户选择了错误的目标文件,而文件又比较大时,能够主动取消而不是望着进度条干着急。
网页端js插入数据行的同时,会设置取消按钮:
js
// 将等待上传的文件插入表格中
function insertEnqueuedItem(fileName, fileSize) {
// 容量不够,需要新增一行
if (toBeUsedRowID >= row_num) {
addRows(1);
row_num++;
}
const $toBeUsedRow = $('.table_row', $content_files).eq(toBeUsedRowID);
$toBeUsedRow.removeClass('empty-row'); // 移除空行样式
$toBeUsedRow.attr('filename', fileName);
$('div:nth-child(1)', $toBeUsedRow).text(toBeUsedRowID + 1);
$('div:nth-child(2)', $toBeUsedRow).text(fileName).attr('title', fileName);
$('div:nth-child(3)', $toBeUsedRow).text(formatFileSize(fileSize));
$('div:nth-child(4)', $toBeUsedRow).text(STRINGS.STATE_WAITING_UPLOAD);
var cancelButton = $('<div class="text-underline cant_select_text">' + STRINGS.OPERATION_CANCEL + '</div>');
$('div:nth-child(5)', $toBeUsedRow).append(cancelButton);
cancelButton.on('click', function () {
if (fileName) {
item = uploadingItems[fileName];
if (item) {
// $.post("/abort");
var uploader = getHtml5Uploader();
uploader.abort(item);
delete uploadingItems[fileName];
}
}
});
toBeUsedRowID++;
}
可以看到,实际的取消逻辑为:
js
var uploader = getHtml5Uploader();
uploader.abort(item);
还记得吗?uploader是个单例,而它abort方法的实现为:
js
aborted: function (item) {
var existFile = updateUploadingState(item, STRINGS.STATE_CANCELED);
clearAttrFilename(existFile);
}
这里仅仅是网页UI层面的更新。实际的中断操作在upload5库中:
js
abort: function (item) {
if (item.status !== bitcandies.FileUploader.Statuses.COMPLETED && item.status !== bitcandies.FileUploader.Statuses.ABORTED) {
for (var i = 0; i < this.queue.length; ++i) {
if (item.id === this.queue[i].id) {
this.queue.splice(i, 1);
item.status = bitcandies.FileUploader.Statuses.ABORTED;
this.options.aborted.call(this, item);
return;
}
}
for (var i = 0; i < this.running.length; ++i) {
if (item.id === this.running[i].id) {
item.status = bitcandies.FileUploader.Statuses.ABORTED;
item.xhr.abort();
this.options.aborted.call(this, item);
return;
}
}
}
},
逻辑很简单,如果是仍在排队等待执行的,设置item状态为bitcandies.FileUploader.Statuses.ABORTED;如果是正在传输的,设置状态的同时调用xhr.abort()中断传输。
在Service服务器端,之前已建立的http连接,会在中断时回调:
kotlin
request.endCallback = CompletedCallback { e: Exception? ->
// 不论传输成功,失败,最后总会调用到这里
completeImportThenNotify(fileUploadHolder)
response.end()
logE(TAG, "fileUploadHolder.reset()")
}
在completeImportThenNotify(fileUploadHolder)中,我们通过比对接收到的文件大小与目标文件总大小是否一致,来判定文件是否完成了完整传输,如果没有则通知UI做相应刷新。
注意:
xhr.abort()是一个"断开连接"的操作,它确保客户端不再接收数据并清理自身状态,但它不能保证服务器端任务的同步停止。
需要特别注意的是:
虽然我们没有做多线程传输,可由于http本身的无状态性,即便是单线程传输也可能发生:一个传输任务客户端用户按下取消按钮->做了中断操作->下一个任务已就绪,启动传输->服务端接收到新任务连接,开始了传输;可上一个任务的
request.endCallback还没有调用
如果你需要正确列出多个任务的传输状态,那么显然需要在formData中传入一个key字段来唯一定位。
优化网页UI
简单的网站效果如下:

十分朴素,据说AI很擅长web ui,今天我们就来鉴定一下。Gemini用量似乎是一小时刷新,到现在也差不多了,我们Vibe coding:
@index_zh.html @index_en.html 的网站UI你给优化下,要酷炫的科技风
它改了点css,输出这个给我: 
我们追问:
改下风格,亮色系,material3,你不应满足于仅修改css,html也可以想办法美观
这次输出:

确实还行,按钮上还给加了两个图标,我们就用它了。
下一节,我们将实现对超长图条漫的支持。