/1,600 字/阅读约 6 分钟

只用一个系统 WebView:把博客装进 14 KB 的安卓壳

好奇心起于一个 3.1 MB 的手机应用——把它拆开、再把它运行时下载的三十个代码包读完,发现它自己几乎什么都不干。于是我反过来给博客做了一个 14 KB 的安卓壳:只用系统自带的 WebView,补上网页本身做不到的返回、断网重试与全屏。本文只讲这件事本身——为什么可以这么薄、壳怎么写、怎么发出去;调试路上踩的坑,另起一篇单说。

眠月眠月
joker1point
本文要点

14 KB 安装包 · 0 第三方依赖 · 1 个 Java 文件 · Android 7.0+

  • 为什么能这么薄:安卓系统自带完整的浏览器内核,多挂一层框架就是多背一份成本。
  • 壳要补哪四件事:同源留在壳内、外域交给系统、返回走网页历史、断网给个说法。
  • 怎么写:一个 Activity 加一个 WebView,两段逻辑(导航分流与返回键)再加一个断网兜底页。
  • 怎么发出去:Gradle 构建签名 → 提交入仓 → 静态站以安装包类型直链下发。

我手机上装着一款体积只有 3.1 MB 的应用,界面该有的都有。某天我决定把它拆开,想知道这么小的安装包里究竟装了什么。

好奇心:拆开一个 3.1 MB 的 App

解包、反编译、确认它用什么写的——很快看清了底牌:它是把 Web 前端装进原生外壳的做法,业务代码根本不在安装包里。顺着它的默认配置,我又把它运行时才去下载的三十个代码分块全部拉下来读完。最后确认了一件反直觉的事:

这个应用自己几乎不干活。 界面、交互、业务逻辑全在远程托管的网页里;那 3.1 MB 绝大部分是「把网页搬进手机」的那层壳,外加一堆前端框架的运行时。

这段经历留给我的不是「怎么照抄」,而是一个更朴素的问题:

如果我只是想给自己的博客开一个直达入口,为什么要扛这么大一整个架子?

我真正需要的其实只有一个 WebView

安卓系统早就自带了一个完整的浏览器内核(WebView)。它不需要额外依赖、不需要联网初始化、不占安装体积。我想做的事——打开直达、能返回、断网有提示——全都能在它之上补齐。多出来的每一层框架,都是我要额外背负的成本。

于是「TechLog 薄壳」这个项目就诞生了。目标定得很死:零第三方依赖、只用系统 WebView、安装包控制在几十 KB 量级。

先想清楚:一个壳究竟要补哪几件事

听起来「打开一个网址」是最简单的事,但真要做得像样,得回答几个问题:

  • 同源链接要留在壳里。点一篇文章如果被甩到系统浏览器,用户会瞬间出戏——那是「跳出去」,不是「用应用」。
  • 外域链接要交给系统浏览器。反过来,博客里指向外站的链接不该被关在这个小窗口里,那是对用户的劫持。
  • 返回键要走网页历史。用户按一次返回,应该是「回到上一篇/上一页」,而不是「啪」一下整个应用退掉。
  • 没网时要有个说法。网页加载失败默认是白屏,对着白屏发呆和看到一个「轻触重试」的提示,体验差得很远。

这四件事,就是整个壳的全部复杂度来源。

URL 分流规则:同源链接留在 WebView,外域链接交给系统浏览器;主帧加载失败时显示本地兜底页
图 1 · 壳内只有两条规则:按域名分流导航,按加载结果决定是否显示兜底页

写壳:一个 Activity 加一个 WebView

实现落在一个 Java 文件里,核心就是一个 WebView 加两段逻辑。

第一段是导航分流,落在 shouldOverrideUrlLoading 里:如果目标地址是 https 且域名属于博客,就放行让 WebView 自己处理;否则抛给系统去打开。

java
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
    Uri uri = request.getUrl();
    if ("https".equalsIgnoreCase(uri.getScheme())
            && SITE_HOST.equalsIgnoreCase(uri.getHost())) {
        return false;          // 同源:留在壳内
    }
    startActivity(new Intent(Intent.ACTION_VIEW, uri));  // 外域:交给系统
    return true;
}

第二段是返回键,落在 onBackPressed 里——有网页历史就后退,没有才退出应用:

java
public void onBackPressed() {
    if (webView.canGoBack()) {
        webView.goBack();
    } else {
        super.onBackPressed();
    }
}

再加上 onReceivedError 里判断「是不是主文档加载失败」,是就把一个本地的兜底文案盖上去,点一下重新加载。最后用 saveState / restoreState 保住旋转屏幕时的页面状态。整套逻辑到此结束,没有一行多余的代码。

配置侧刻意保持极简:权限只要一个 INTERNET,图标全部用矢量图(不塞任何 PNG 素材),最低支持到 Android 7.0。这些克制最后换来的,是一个 14 KB 的安装包——比一张普通的手机截图还小。

发出去:从 Gradle 到 GitHub Pages

壳写通了,接下来要让别人也能拿到它。链路不复杂,但每一步都要刻意对齐:

发布链路:Gradle 构建签名 APK,提交到仓库下载目录,推送后由 GitHub Actions 构建静态站,再由 GitHub Pages 以下载类型直链下发
图 2 · 从源码到用户可下载:构建、入仓、构建站点、页面下发

有个细节值得一提:把 APK 放进静态站时,服务器必须用安卓安装包专用的类型下发,浏览器才会把它当成「可安装的包」,而不是一个陌生文件。验证也简单——下载一次,比对哈希与本地是否一致。一致。

然后在博客的「关于」页加了一个下载入口:

博客关于页的 Android 客户端卡片:一段说明文字、版本信息与紫色的下载按钮
图 3 · 关于页的下载卡片(线上实拍)

剥掉多余的东西,剩下的才值得亲手写

回看整件事,最值得记下的不是代码,而是一个判断:

「别人那么做」和「你需要那么做」是两回事。 拆开那个 3.1 MB 的应用,我学到的不是怎么模仿它的架构,而是反过来看清:那么多层,对「我」这个场景,根本可以不要。

一个 14 KB 的壳,装的是一整座博客。而把这些多余的东西一层层剥掉之后,剩下的那点复杂度,恰好就是我想亲手写清楚的部分。

至于路上踩的那些坑——模拟器不联网、渲染静默回退、桌面上消失的图标——我在另一篇里单独记了。