让 PC 网页在手机上真正能用。
📦 下载 APK · 1.8 MB · Android 5.0+ · MIT 协议
一个 Android 浏览器,只专注做一件事:那些没有做移动端适配的网页,在手机上也得好用。
手机上打开这类网站,体验通常是灾难 —— 字小到要放大镜、排版整个错位、 按钮挤在一起点不中、表格横向溢出。它们本来就是按 1280px 的桌面屏设计的, 而手机浏览器的默认行为是「按移动端 viewport 渲染」,两边对不上。
Blauser 反过来做:按 PC 的宽度渲染,再整体缩放贴合手机屏。
同时伪装 window.screen.width,让响应式站点主动吐出 PC 布局。
于是后台管理系统、老论坛、文档站、政府网站这些「只在电脑上能看」的页面,手机上也能用了。
左起:PC 页面缩放 · 新标签页 · 悬浮球菜单 · 深色模式
1. 不是「缩放到适应」,是「按桌面布局渲染后再缩放」
普通浏览器把桌面页硬塞进 375px 宽,结果是断行错乱、侧边栏盖住正文。 Blauser 让页面按 1280px(或你指定的宽度)正常排版 —— 该并排的并排、该分栏的分栏 —— 然后等比缩放到手机屏。你看到的是完整的桌面布局,只是小了一号,双指放大即可看清。
而且它会判断:页面自己声明了 width=device-width(说明它做了移动端适配),
就完全不干预,保持原生渲染。所以淘宝、知乎这类站点仍然是正常的移动端体验,
只有真正的 PC 页面才被接管。
2. 界面上只有一个控件
没有状态栏、没有地址栏、没有标签栏、没有底部导航 —— 网页铺满整屏。 所有操作(后退、刷新、收藏、历史、复制链接、设置……)都从屏幕边缘那个 32dp 宽的半球进出。 短按弹菜单,长按直达设置,可拖动、自动贴边、位置记忆、空闲时半透明不挡内容。 无痕模式下这个半球会变成深色 —— 它是界面上唯一能承载「这个标签不留痕」这个信息的地方。
3. 标签页就是系统任务
每个标签跑在独立的 Android task 里,会出现在系统「最近任务」中各占一张卡片。 切换标签 = 切换任务,用系统的手势就能完成,不需要在浏览器里再找一层标签列表。 而且因为这些 task 共享同一个进程,cookie / localStorage 照常共享,登录态不会丢。
4. 一个网站一套设置
有些站点只在特定配置下才正常 —— 只认桌面 UA、必须横屏、要特定语言。 与其来回改全局设置,不如按域名记一份覆盖:离开这个站点自动失效, 没覆盖的项跟随全局。还提供「桌面模式 / 移动模式」一键预设。
5. 该有的浏览器能力都不缺
文件上传、下载、摄像头 / 麦克风 / 定位、三方 cookie(很多站点的内嵌登录靠它)、 深色模式、添加到桌面。不用因为「某个功能它没有」而临时切回别的浏览器。
- PC 页面自动适配:自动判断页面类型,PC 页面按桌面宽度渲染后整体缩放
- 分辨率可选:
自动(默认)/跟随手机/1280 × 720/1366 × 768/1024 × 768/800 × 600/自定义- 预设档只定宽度:按该宽度排版,再整体缩放贴合手机屏宽,纵向照常滚动。
同时伪装
window.screen.width,响应式网站会据此输出 PC 布局 - 自定义可以精确填「宽 × 高」。填了高度就按这个比例模拟一块屏幕: 等比缩放、四周留白(下图就是自定义 1280 × 720 的效果)。 高度留空表示不限高,退化成只按宽度缩放
- 预设档只定宽度:按该宽度排版,再整体缩放贴合手机屏宽,纵向照常滚动。
同时伪装
- UserAgent 可选:Android 手机 / 桌面 Chrome / iPhone Safari / 自定义
- 缩放开关:默认关闭,而且是按网站设的 —— 单指点按和双指捏合都不会缩放页面。 同一个浏览器里「看文档要能放大」和「刷信息流怕误触」会同时存在,全局一个开关满足不了, 所以在悬浮球菜单里单独切(菜单项文字会带出当前是开还是关),本站设置里也有勾选项
- 深色模式:跟随系统
- 屏幕方向:跟随系统 / 竖屏 / 横屏
- 语言:同时作用于
Accept-Language请求头和navigator.language - 搜索引擎:百度 / Google,默认百度。作用于地址栏里不像网址的输入
- 以上每一项都可以按域名单独覆盖(缩放本身就是按域名的)
自定义分辨率 1280 × 720:按 16:9 渲染,上下留白,内容不变形
- 悬浮球:贴长边的半球 —— 竖屏贴左右、横屏自动横过来贴上下;
可拖动、自动贴边、位置记忆、空闲半透明。贴顶边时会避开刘海
- 短按展开菜单:后退 / 前进 / 刷新 / 允许缩放 / 首页 / 新建无痕标签 / 收藏本页 / 收藏夹 / 历史记录 / 复制链接 / 分享 / 添加到桌面 / 本站设置 / 全局设置 / 审查元素
- 长按直达设置
- 无历史时「后退 / 前进」置灰;空白标签上「收藏 / 复制链接 / 分享 / 添加到桌面 / 本站设置」置灰 —— 这些都要有网页才有意义
- 已收藏时「收藏本页」变成「取消收藏」
- 设备不支持无痕(见下)时,那一项不显示而不是置灰 —— 摆一个永远点不动的灰色项只会让人困惑
- 新标签页 / 首页(定制页面):
- 地址输入框 —— 输入网址或搜索词;自动补全协议,
localhost:3000这类本地地址补http://,其余补https:// - 打开的标签页列表,点击即切换;无痕标签带「无痕」角标
- 收藏的网址列表,点击打开;若该网址已经开着,则切过去而不是重复开
- 上面两个列表、以及收藏夹 / 历史记录对话框里,长按任意一行即复制该行网址
- 地址输入框 —— 输入网址或搜索词;自动补全协议,
- 无效证书:证书校验失败时不再一律拒绝,而是弹窗让你选「取消」还是 「继续访问(不安全)」。选继续会永久记住这个域名的这张证书,之后不再询问; 证书换了会重新问。撤销入口有两个:本站设置里的「证书例外」一行 (只在该域名有例外时出现),或全局设置 →「证书例外」清单(逐条删除 / 清空)
- 无痕标签:独立存储,cookie / localStorage / 缓存与普通标签完全隔离,
关闭后整份数据销毁。也不写浏览历史。
依赖 WebView 的多 profile 能力,条件有两个:Android 9 及以上,
且 WebView 本身支持多 profile(较新版本的 WebView)。
两者缺一就不显示这个入口 —— 而不是做个「不记历史但 cookie 照样共享」的假的。
实际判断走
WebViewFeature.isFeatureSupported(MULTI_PROFILE),两个条件都包含。 - 浏览历史:记录访问过的页面,同一网址只留一条并置顶,上限 500 条, 可一键清空(清空有二次确认)。无痕标签不写历史。
- 文件上传(
<input type="file">,支持多选) - 下载(走系统 DownloadManager,透传 cookie 与 UA,完成后可在系统「下载」里看到)
- 摄像头 / 麦克风 / 定位(网页请求时才向你申请权限)
- 三方 cookie
- 添加到桌面:把当前网页固定成桌面快捷方式,点击以独立任务打开
中文 / English,跟随系统。文案全部走 strings.xml,加一门语言只需再加一份
values-<语言>/strings.xml。
- 注册为系统默认浏览器,其他 App 点链接可以直接用这个打开(会开新标签,不打断当前页面)
- 注册为本地 HTML 文件的打开方式:在文件管理器里点
.html/.htm/.xhtml文件时可以选择用本 App 打开。file://与 Android 7+ 的content://都支持 (后者会先复制到应用缓存,再按本地文件加载) - 应用内检查更新:悬浮球菜单里有「检查更新」,每天首次启动时也会静默检查一次 GitHub Releases;发现新版本后可下载 APK 并调起系统安装器
设置面板 · 历史记录 · 自定义错误页(按错误码给出中文原因,替代系统那张 “net::ERR_…”)
到 Releases 页下载最新版 · 1.8 MB
- 需要 Android 5.0(API 21)及以上
- 没有上架任何应用商店,首次安装需在「设置 → 安全」里允许安装未知来源的应用
- Release 页面里附有 APK 的签名 SHA-256,可自行核对
装好后可以去系统「设置 → 应用 → 默认应用 → 浏览器应用」里把它设成默认浏览器。
git clone https://github.com/SmartXiaoMing/blauser.git
cd blauser
./gradlew assembleRelease
# 产物:app/build/outputs/apk/release/blauser-<版本号>.apk
adb install -r app/build/outputs/apk/release/blauser-*.apk环境要求:JDK 17+、Android SDK(local.properties 里写 sdk.dir=...)。
Gradle 用仓库自带的 wrapper 即可,不需要另装。
- 打开网页:点悬浮球 → 首页 → 输入网址或搜索词
- 新开标签:首页里点「空白页」那一行,或在网页里点
target="_blank"链接 - 切换标签:用系统的最近任务手势(每个标签是一张独立卡片), 或者从首页的「打开的标签页」列表里点
- 关闭标签:首页列表里点该行右侧的 ×,或者在最近任务里划掉
- 收藏:悬浮球 → 收藏本页
- 复制链接:悬浮球 → 复制链接(复制当前页);首页的标签 / 收藏列表、 收藏夹、历史记录里长按任意一行则复制那一行的网址
- 自签名 / 过期证书的站点:证书警告里选「继续访问」,该域名的这张证书会被永久记住; 想反悔就去本站设置或全局设置 →「证书例外」里撤销
- 针对某个网站改设置:悬浮球 → 本站设置
- 审查元素:悬浮球 → 审查元素(仅 debug 包开启远程调试)
这些是设计上明确接受的取舍,不是 bug。
| 场景 | 页面状态是否保留 | 说明 |
|---|---|---|
| 切换标签页、切到后台再回来 | ✅ 完整保留 | DOM / JS 变量 / 滚动位置 / 表单输入都在 |
| 屏幕旋转 / 改字体大小 / 改语言 | ✅ 完整保留 | 已声明 configChanges,Activity 不重建 |
| 切换深色模式 | 不重建 Activity 就没法给界面换肤。前进后退历史与滚动位置会保住 | |
| 后台标签被系统回收 | ❌ 页面会重载 | 见下。URL、前进后退历史、滚动位置可恢复,DOM 与表单内容不行 |
| 主动刷新 / 渲染进程被回收 | ❌ 会重载 | 符合预期 |
为什么后台标签会被回收:标签改成独立 task 后,后台 task 的 Activity 处于 stopped 状态, 内存紧张时系统可以销毁它。这与「切回来绝不重载」不可兼得 —— 把所有标签的 WebView 塞进同一个前台 Activity 可以 100% 保证不重载,但那样就没法在最近任务里分卡片了。 本项目选择后者,并明确接受前者的代价。
被回收后能恢复到什么程度:URL、前进后退历史(WebView.saveState)、滚动位置。
恢复不了 DOM 和表单内容 —— WebView 没有序列化页面状态的接口,
硬做只能自己拦截每个输入框,代价远大于收益。
其它限制:
- 无痕标签需要 Android 9 及以上,且 WebView 支持多 profile。两个条件缺一都做不出 真隔离(WebView 的多 profile 是随 WebView 版本走的,跟系统版本不完全同步)—— 与其做个「假装无痕」,不如不提供这个入口
- 滚动位置是尽力而为:页面重载后要等排版稳定(图片撑开高度、懒加载补内容), 恢复得太早会被后续布局吃掉。代码里是固定延迟 300ms,极端页面可能不准
- 证书例外按「域名 + 证书指纹」记,而且不参与备份:换设备 / 重装后会重新问一遍。 这是刻意的 —— 一份用户看不见的信任清单不该跟着备份走
- 撤销证书例外要重启 App 才生效。同一次运行里 WebView 自己记着那次放行 (见「安全上的取舍」),而这份记忆没有公开接口能清掉。所以撤销之后: 记录立刻没了,但当前这次运行访问该域名仍然不会问;重启 App 后恢复询问
- 语言:Kotlin + viewBinding。配色与文案全部走资源
(
colors.xml+values-night+strings.xml),没有散落的硬编码 - 最低版本:Android 5.0(API 21),targetSdk 34
一个 MainActivity 实例 = 一个标签页 = 一个 Android task。
开新标签用 Intent(FLAG_ACTIVITY_NEW_DOCUMENT or FLAG_ACTIVITY_MULTIPLE_TASK) 启动新实例,
系统即为它创建独立 task 与最近任务卡片。
每个标签的启动 Intent 带一个唯一的 data URI(blauser://tab/<uuid>)作为稳定标识 ——
实测 task 的 baseIntent 会保留它,因此可以据此枚举标签、moveToFront() 切过去、
finishAndRemoveTask() 关掉。
这个标识必须唯一:外部 App 用 ACTION_VIEW 调起时,Intent 的 data 是网址本身,
不能拿来当标签标识 —— 同一个网址被打开两次会撞成同一个 key,
导致两个标签的状态互相覆盖、关一个还关俩。所以这类 Intent 不会被就地加载,
而是转交给一个带 uuid 的正经标签页。
注入 JS 改写 viewport meta 并计算 scale;固定档位模式下同时用
Object.defineProperty 伪装 window.screen.width / availWidth。
「自动」档在 DOMContentLoaded 判断(此时 <head> 已解析完,比 onPageFinished 早得多,
能明显减少重排闪烁):
| 页面类型 | 判定 | 处理 |
|---|---|---|
有 width=device-width |
移动端适配页 | 不干预,保持原生渲染 |
| 无 viewport meta 或固定宽度 | PC 页面 | 按 1280 渲染后整体缩放贴合屏幕 |
- 混合内容用
MIXED_CONTENT_COMPATIBILITY_MODE:放行 http 图片等被动资源, 但拦下 http 脚本 allowFileAccess默认关闭,仅本地 HTML 标签页临时打开:地址栏仍然不放行 任意file://输入;打开本地 HTML 时需要读同目录的 CSS / 图片,所以只跟着主文档 地址开关(跳到网页立即收回)。allowContentAccess保持关闭- 更新包只接受 GitHub Releases 的 https 地址:重定向每一跳都要求 https; 下载后校验包名、版本号和 versionCode,release 提供 SHA-256 时还会校验摘要; 签名一致性交给系统安装器(覆盖安装时签名不符会被拒绝)
- 外部协议跳转要求用户手势:没有手势的
weixin:///intent://跳转一律拦下 —— 网页静默拉起 App 是最常见的骚扰与诱导手段(Chrome 同样策略)。 弹窗(window.open)也只放行用户点出来的 intent://另做了三处加固: 清掉解析出来的 component / selector(否则网页能借本 App 的身份去拉起别的应用里 没有导出的组件,即 Google 所说的 Intent Redirection);startActivity连SecurityException一起捕获(目标未导出或带android:permission时抛的是它,只捕ActivityNotFoundException会让恶意网页直接搞崩 App);browser_fallback_url必须是 http/https —— 它完全由网页控制, 放行javascript:会在当前页面上下文里执行脚本- 证书例外逐条记录,而不是给一个「忽略证书错误」的总开关:存的是
域名 + 证书指纹(见SslExceptionStore)。只按域名记的话,这个域名以后任何 证书都会被放行 —— 用户当初接受的明明是「那张自签证书」,不是「这个域名以后可以被 随便冒充」。绑上指纹后,证书一换就会重新问。取不到指纹时宁可不记 (本次放行、下次再问),也不退化成整站无条件放行- WebView 自己有一份进程内的记忆:放行过一次的域名,在同一次运行里不会再回调
onReceivedSslError(实测:撤销例外后另开一个标签访问同一域名,TLS 握手确实 重新失败,但回调根本没发生,页面直接加载)。这份记忆没有公开接口可读可清, 进程一退就没了 —— 所以「永久记住」必须由应用自己落盘,两者是互补的: 进程内靠 WebView,跨进程靠这份记录 - 例外是按域名记的,不区分端口:同一台机器上的
https://nas:8443与wss://nas:9443共用一个例外。证书本来就是按主机签的,分端口记只会让人 在同一台机器上被问很多次 - 无痕标签里不落盘,只放行本次 —— 那正是「无痕」的意义
- WebView 自己有一份进程内的记忆:放行过一次的域名,在同一次运行里不会再回调
- 网页里的
wss://也能被「继续访问」救回来(实测通过)。WebView 会把非主文档的 证书错误一并交给应用 —— 这是它和 Chrome 的刻意差异,Chromium 为此专门加了 resource type 管线(提交说明:"the Chromium based webview needs to process and pass SslErrors to the application even for the non-Main-Frame resource types")。 对话框会写清出错的是「这个网页请求的资源」,并且拒绝时不会用错误页盖掉页面 —— 子资源出错时页面本身是好的。代价是页面上一个坏证书的图片也可能弹一次窗 allowBackup="false"+ 备份规则全排除:应用数据目录里有 WebView 的 cookie / localStorage,那是用户登录态,不该跟着云备份或换机迁移走
app/src/main/java/com/blauser/browser/
├── MainActivity.kt # 一个实例 = 一个标签页;生命周期、覆盖层、回调分发
├── TabRegistry.kt # 标签 = task 的枚举 / 切换 / 关闭 + 各标签状态登记
├── WebViewFactory.kt # 唯一构造 WebView 的地方(WebSettings + 两个 client)
├── PageScaler.kt # viewport 缩放与 screen 伪装
├── UrlHelper.kt # 地址栏输入解析(纯函数)
├── SettingsManager.kt # 全局设置持久化
├── SiteSettingsManager.kt # 按域名覆盖全局设置
├── SslExceptionStore.kt # 「这个域名的这张证书我认了」(域名 + 指纹,可撤销)
├── SettingsDialogs.kt # 全局设置 / 本站设置两个面板
├── PageActions.kt # 收藏 / 复制 / 分享 / 桌面快捷方式 / 审查元素
├── DownloadHandler.kt # 下载(走系统 DownloadManager)
├── FloatingBallView.kt # 半球悬浮球与下拉菜单(菜单项数据驱动)
├── BookmarkManager.kt # 收藏夹存储(含旧数据迁移)
└── UrlListDialog.kt # 通用网址列表对话框(收藏夹 / 历史 / 证书例外共用)
资源目录:
app/src/main/res/
├── values/colors.xml # 浅色配色 token
├── values-night/colors.xml # 深色配色 token(与上面一一对应)
├── values/strings.xml # 全部用户可见文案
├── xml/data_extraction_rules.xml, backup_rules.xml # 备份全排除
└── layout/ drawable/ # 布局与图标(颜色全部引用 @color token)
./gradlew assembleDebug # 调试包
./gradlew assembleRelease # 发布包(R8 + 资源压缩)
./gradlew testDebugUnitTest # 单元测试
./gradlew lintDebug # 静态检查(有 error 会中断构建)发布签名从 local.properties 读取(该文件不进版本库),不配的话自动回落到 debug 签名:
sdk.dir=/path/to/android-sdk
RELEASE_STORE_FILE=../release.jks
RELEASE_STORE_PASSWORD=...
RELEASE_KEY_ALIAS=...
RELEASE_KEY_PASSWORD=...单元测试覆盖纯逻辑部分:地址栏输入解析、缩放脚本生成、UA 解析、收藏夹 JSON 读写与容错。
./gradlew testDebugUnitTestUI 与 WebView 行为没有自动化测试,改动后需要在真机上过一遍。
# 查看当前有几个标签(= 几个 task)
adb shell dumpsys activity activities | grep -oE "Task\{[a-f0-9]+ #[0-9]+ type=standard A=[0-9]+:com.blauser.browser" \
| sed -E 's/.*#([0-9]+).*/\1/' | sort -un
# WebView 远程调试(debug 包自动开启,release 包关闭)
adb forward tcp:9222 localabstract:webview_devtools_remote:$(adb shell pidof com.blauser.browser)
curl -s http://localhost:9222/json # 每个标签页是一个独立的 page target小飞象启动图标由 tools/gen_icon.py 生成(几何定义是唯一来源,同时输出自适应图标的
矢量 XML 和 API 21-25 用的各级 PNG)。改图标设计时改该脚本重新生成即可。
- 多语言支持(中文 / English,跟随系统)
- 浏览历史(去重置顶、上限 500、一键清空)
- 无痕标签(Android 9+,独立 profile 真隔离)
- 标签被系统回收后的状态恢复(URL + 前进后退历史 + 滚动位置)
- 文件上传 / 下载 / 摄像头麦克风定位权限 / 三方 cookie
- 深色模式
- 按域名覆盖设置
- 无效证书自己说了算(拒绝 / 继续访问并永久记住,按域名 + 指纹记,可撤销)
- 添加书签、多标签页、标签出现在系统最近任务中
- 打开本地 HTML 文件(注册为
.html/.htm的系统打开方式) - 应用内检查更新 / 下载 APK 升级(GitHub Releases,每天启动时自动检查一次)
写在这里是为了不让人反复提同一个建议 —— 这几项都是想清楚了不做的,不是没想到。
- 广告 / 跟踪拦截:
shouldInterceptRequest拦每个请求会明显拖慢页面加载, 而维护一份可用的规则表是个持续投入的工程。想拦广告就用专门的扩展或 DNS。 - 标签页分组 / 批量关闭:标签就是系统最近任务里的卡片, 系统自己提供了切换和批量清理,再叠一层是重复建设。
- 恢复表单内容:见上方「已知限制」,WebView 没有序列化页面状态的接口。






