NW-HTMLviewer 移动端 HTML 工作台
一款工业级移动端 HTML 编辑器与全屏预览应用:CodeMirror 6 离线编辑、双渲染内核(系统 WebView / GeckoView)、离线资源缓存与完整文件管理,一次围绕「浏览器能力边界」的工程实践。
目录
按章节顺序排列,点击直接跳转。
对 WebView 能力边界感兴趣的同学,建议重点看 01 / 02 / 05 三节。
项目概览
正文里出现过的关键读数先汇总在这里:渲染内核版本、API 级别、桥接方法与缓存上限。
- [*]Renderer 接口loadHtml · loadFile · reload · executeJs · destroy
- [*]能力探测属性touchpad · console · resourceCache
- [*]构建变体Full / Lite(GECKO_ENABLED)
- [*]Binder 单次调用上限32 KB
- [*]内容分块粒度32k · 50k · 64k
- [*]触摸板灵敏度1.6
- [*]回收站撤销窗口5 s
- [*]缓存响应头max-age=31536000, immutable
- [*]缓存文件名规则sha1(url) 前 24 位 + 扩展名
- [*]技术栈Kotlin 2.3 · AGP 8.13 · Room · Hilt
- [*]开源许可GPL-3.0
双渲染内核
把渲染抽象成一个 Renderer 接口:能力先探测、再降级,而不是假装拥有。
预览场景最大的痛点是渲染行为不一致:同一段 HTML 在系统 WebView 与
GeckoView 下表现可能完全不同。项目把渲染抽象成一个 Renderer 接口,
统一提供 loadHtml / loadFile / reload / executeJs / destroy,并配一组
能力探测属性——触摸板、console、资源缓存各自是否支持——不支持的
能力直接降级,而不是假装拥有。
interface Renderer { val view: View fun loadHtml(html: String, baseUrl: String?) fun loadFile(file: File) fun executeJs(script: String) fun reload() fun destroy() // 能力探测:做不到的就诚实降级 val touchpadSupported: Boolean val consoleSupported: Boolean val resourceCacheSupported: Boolean}
- 轻量模式(WebViewRenderer):系统 Chromium 内核,零额外开销,拥有 console 收集、请求拦截、
evaluateJavascript全部能力; - 兼容模式(GeckoRenderer):内置 GeckoView 153,独立内核、渲染行为固定,配合持久磁盘缓存;代价是 Gecko 没有公共的 JS 注入 / console 回调 / 请求拦截 API,这些特性随之关闭;
- Full / Lite 两种构建变体由
GECKO_ENABLED编译开关控制,Lite 版仅系统 WebView,体积缩小约 99%。
"好的抽象不是把所有内核伪装成一样,而是声明各自能做什么、不能做什么。"
离线资源缓存
首次加载时把 http(s) 子资源固化到本地,之后 URL 不变、直接走本地。
移动端编辑 HTML 最头疼的是依赖 CDN 的页面一离线就没法看。方案是首次加载时把
http(s) 子资源下载并固化到 HTML 同目录的隐藏文件夹
.htmlviewer_cache,之后 URL 不变直接走本地。实现挂在
shouldInterceptRequest 里:先查内存索引 serve 命中,未命中
再 download 固化后返回。
override fun shouldInterceptRequest( request: WebResourceRequest): WebResourceResponse? { val url = request.url.toString() if (!url.startsWith("http")) return null // 命中缓存直接返回本地响应 resourceCache.serve(url)?.let { return it } // 未命中:下载并固化到 .htmlviewer_cache return resourceCache.download(url)}
- 索引为每行一条 TSV 记录的
index.tsv(url / fileName / mime / size / time / hash),内存持有镜像避免每次读盘; - 文件名取
sha1(url)前 24 位 + 扩展名,临时文件 +renameTo原子落盘,单资源上限 20MB; - 响应携带
Cache-Control: max-age=31536000, immutable长缓存头,让 WebView 自身也参与命中。
Gecko 内核没有请求拦截 API,此功能仅 WebView 可用——这正是上一节「能力探测」落地的例子。
CodeMirror 集成
离线 IIFE bundle + HVBridge 跨进程通信,以及 Binder 限制下的一串坑。
编辑器本体是 CodeMirror 6,用 esbuild 在构建期打成离线 IIFE bundle
放进 assets,运行时由独立 WebView 加载 editor.html,Android 侧通过
HVBridge 这个 JavascriptInterface 与页面通信。踩过的坑比想象的多:
- Binder 32KB 限制:单次跨进程调用有大小上限,内容按 32k / 50k / 64k 分块拉取与回写(
saveChunk / getContentChunk); - EditContext 开关:Chromium ≥ 126 的 EditContext 会让 Android 端触摸与滚动失效,必须设置
EditorView.EDIT_CONTEXT = false; - 一键格式化:prettier standalone 按文件扩展名选 parser(html / css / babel),插件首次使用时才动态加载;
- 滚动双保险:JS 端 touchmove 接管滚动 + Kotlin 侧
onTouchEvent每 50ms 检查一次__hvTouchSeen,JS 接管失效就注入HVEditor.scrollBy兜底; - 超过 100 万字符自动关闭语法高亮,保住输入性能。
"把编辑器跑在 WebView 里不难,难的是让它在 Binder 限制、EditContext 巨变和触摸失效之间仍然稳定。"
编码兼容
检测逻辑 BOM 优先,没有 BOM 就靠严格解码试出真实编码。
存量 HTML 大量是 GBK。检测逻辑BOM 优先(UTF-8 / UTF-16LE / UTF-16BE), 没有 BOM 就尝试「严格 UTF-8 解码」能否成功,失败再试 GB18030(GBK 超集)严格解码, 仍失败则兜底 UTF-8。检测结果随文件元数据持久化,编辑保存保持原编码,GBK 文件可一键转存 UTF-8。
fun detect(bytes: ByteArray): TextEncoding { // BOM 优先 if (bytes.startsWith(UTF8_BOM)) return UTF_8 if (bytes.startsWith(UTF16LE_BOM)) return UTF_16LE if (bytes.startsWith(UTF16BE_BOM)) return UTF_16BE // 严格 UTF-8 解码失败,再试 GB18030(GBK 超集) if (canDecode(bytes, Charsets.UTF_8)) return UTF_8 if (canDecode(bytes, GB18030)) return GBK return UTF_8 // 兜底}
注意严格解码要用 CodingErrorAction.REPORT——默认的 REPLACE 会把非法字节
悄悄替换成「?」,导致乱码被误判为正常文本。
模拟鼠标
合成事件的注入补偿:默认行为与伪类都要手动补回来。
预览模式支持触摸板式模拟鼠标:屏幕上出现一个箭头光标,单指滑动按
相对位移(灵敏度 1.6)移动光标,轻点等于单击,双指上下滑滚动页面。因为全部是 JS 注入的
合成事件,浏览器不会自动触发 CSS :hover / :active,也不会执行
默认行为——所以高亮要手动打,a 跳转、checkbox 切换、input 聚焦这些默认行为
也要手动补。
// 单指滑动:相对位移移动光标touch.addEventListener('move', function (e) { const dx = (e.dx * 1.6) | 0; const dy = (e.dy * 1.6) | 0; moveCursor(dx, dy); // 合成事件不触发 :hover,手动打高亮并派发 Mouse + Pointer const el = document.elementFromPoint(cx, cy); hover(el); dispatchMouse(el, 'mousemove');});
- 双指滚动:
scrollByPx向上查找可滚动容器直接改scrollTop(合成 wheel 事件不会触发默认滚动); - 手势接管:capture 阶段监听 +
touch-action: none; - 注入脚本带
__HV_TP_CLEANUP__完整拆卸,页面跳转后自动重注入,注入失败重试 5 次。
文件管理与回收站
文件本体与元数据分离;删除走同分区 rename,换来 5 秒可撤销。
文件本体与元数据分离:文件在应用专属目录(无需存储权限),收藏、最近打开、编码记忆等
元数据存 Room。file_meta 表以绝对路径为主键,保存行数/字符数等统计,
列表扫描时实时合并进 UI。
@Entity(tableName = "file_meta")data class FileMetaEntity( @PrimaryKey val path: String, // 绝对路径主键 val isFavorite: Boolean, // 是否收藏 val groupId: Long?, // 收藏分组 val lastOpenedAt: Long?, // 最近打开 val encoding: String, // UTF-8 / GBK / UTF-16LE val lineCount: Int, val charCount: Int, val createdAt: Long)
删除做了5 秒可撤销:文件不直接删,而是 renameTo 到同挂载点的
.htmlviewer-trash——rename 底层是 rename(2),跨文件系统会
EXDEV 失败,所以回收站必须与应用根目录在同一分区。撤销栈用 ArrayDeque 记录
(trashFile, originalPath),Snackbar 提供「撤销」入口,5 秒 TTL 到期自动清理,
进程启动时还会清掉上次的残留。
写在最后
把「浏览器能力边界」当成第一公民之后,留下的选型与收束。
这个项目最大的收获,是把「浏览器能力边界」当成第一公民来设计:内核不同、API 有无、 Binder 限制、编码差异,每一项都先探测、再降级、最后兜底。技术选型上 Jetpack Compose + Material 3 撑起 UI,MVVM + Repository + Hilt 撑起架构, Room + DataStore 管数据,CodeMirror 6 与双内核管渲染——层层解耦,各自可替换。