双渲染内核
预览场景最大的痛点是渲染行为不一致:同一段 HTML 在系统 WebView 与
GeckoView 下表现可能完全不同。项目把渲染抽象成一个 Renderer 接口,
统一提供 loadHtml / loadFile / reload / executeJs / destroy,并配一组
能力探测属性——触摸板、console、资源缓存各自是否支持——不支持的
能力直接降级,而不是假装拥有。
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%。
"好的抽象不是把所有内核伪装成一样,而是声明各自能做什么、不能做什么。"
离线资源缓存
移动端编辑 HTML 最头疼的是依赖 CDN 的页面一离线就没法看。方案是首次加载时把
http(s) 子资源下载并固化到 HTML 同目录的隐藏文件夹
.htmlviewer_cache,之后 URL 不变直接走本地。实现挂在
shouldInterceptRequest 里:先查内存索引 serve 命中,未命中
再 download 固化后返回。
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 集成
编辑器本体是 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 巨变和触摸失效之间仍然稳定。"
编码兼容
存量 HTML 大量是 GBK。检测逻辑BOM 优先(UTF-8 / UTF-16LE / UTF-16BE), 没有 BOM 就尝试「严格 UTF-8 解码」能否成功,失败再试 GB18030(GBK 超集)严格解码, 仍失败则兜底 UTF-8。检测结果随文件元数据持久化,编辑保存保持原编码,GBK 文件可一键转存 UTF-8。
// 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 次。
文件管理与回收站
文件本体与元数据分离:文件在应用专属目录(无需存储权限),收藏、最近打开、编码记忆等
元数据存 Room。file_meta 表以绝对路径为主键,保存行数/字符数等统计,
列表扫描时实时合并进 UI。
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 与双内核管渲染——层层解耦,各自可替换。