01

双渲染内核

预览场景最大的痛点是渲染行为不一致:同一段 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%。
"好的抽象不是把所有内核伪装成一样,而是声明各自能做什么、不能做什么。"

02

离线资源缓存

移动端编辑 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 可用——这正是第一节「能力探测」落地的例子。


03

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 巨变和触摸失效之间仍然稳定。"

04

编码兼容

存量 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 会把非法字节 悄悄替换成「?」,导致乱码被误判为正常文本。


05

模拟鼠标

预览模式支持触摸板式模拟鼠标:屏幕上出现一个箭头光标,单指滑动按 相对位移(灵敏度 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 次。

06

文件管理与回收站

文件本体与元数据分离:文件在应用专属目录(无需存储权限),收藏、最近打开、编码记忆等 元数据存 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 到期自动清理, 进程启动时还会清掉上次的残留。


07

写在最后

这个项目最大的收获,是把「浏览器能力边界」当成第一公民来设计:内核不同、API 有无、 Binder 限制、编码差异,每一项都先探测、再降级、最后兜底。技术选型上 Jetpack Compose + Material 3 撑起 UI,MVVM + Repository + Hilt 撑起架构, Room + DataStore 管数据,CodeMirror 6 与双内核管渲染——层层解耦,各自可替换。

Kotlin 2.3 AGP 8.13 minSdk 26 GPL-3.0