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

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也可以想办法美观

这次输出:

确实还行,按钮上还给加了两个图标,我们就用它了。

下一节,我们将实现对超长图条漫的支持。

相关推荐
其实是白羊1 小时前
CoderTools 1.5.3:让 AI 帮你看懂代码调用链路
后端·ai编程·vibecoding
Hector_zh5 小时前
实战·第八篇:当模型陷入死循环——FACA破解JSON生成的架构陷阱
人工智能·agent·vibecoding
柯倪1 天前
使用ai编程一年多,我遇到的问题以及解决思路
vibecoding
duanze2 天前
从零开始Android商业项目Vibe coding完全指南(七)
app·vibecoding
卡卡罗特AI5 天前
有了 DESIGN.md 后,大家也能写出高颜值的网站了!
ai编程·vibecoding
kunge20135 天前
1. OpenSpec 命令执行过程与 Claude Code 提示词详解
vibecoding
自传.6 天前
尚硅谷 Vibe Coding|第三章(1) Claude Code深度使用与进阶技巧 学习笔记
笔记·学习·尚硅谷·vibecoding
文艺倾年6 天前
【强化学习】数学推导专题,20W字总结(十五)
人工智能·分布式·大模型·强化学习·vibecoding
Captaincc7 天前
TRAE AI创造力大赛,正式启动!
trae·vibecoding
方白羽7 天前
一份 AGENTS.md,让 Android AI 代码规范率飙升
android·app·客户端