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

这次输出:

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

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

相关推荐
-嘟囔着拯救世界-2 小时前
Claude Code 平替来了?DeepSeek-TUI 保姆级安装教程
人工智能·ai·ai编程·deepseek·vibecoding·deepseek-tui
摆烂工程师16 小时前
教你解决登录 Codex 需要 WhatsApp 电话号码验证,绕过 Codex 二次验证的教程
openai·ai编程·vibecoding
深念Y19 小时前
多 Agent 对证循环协作架构:Hermes + Claude Code + Codex 三角色工作流实战
ai·工作流·codex·vibecoding·claudecode·skills·hermes
私人珍藏库2 天前
【Android】Soul v5.86.0 内置模块版
android·app·工具·软件·多功能
SpikeKing3 天前
LLM - 集成 Hermes Agent 与 WebUI 至同一个 Docker 镜像配置
docker·webui·vibecoding·hermes agent
小小小小小鹿3 天前
# Vibe Coding 实战:Flutter 滑动列表上的花式动效
flutter·vibecoding
路光.4 天前
uniapp中解决webview在app中调用,有过渡空白问题,增加过渡动效
uni-app·vue·app·uniapp
SuperherRo4 天前
APP攻防-资产收集篇&反代理&反证书&反模拟器&Msgisk&LSP模块&系统证书
app·抓包·burp·反证书·反模拟器·反代理