70KB 单文件数独: 数独生成器 + Android 16 手动出 APK 踩坑全记录

26 阅读6分钟

做这个数独游戏其实是个意外。

三个月前我妈给我推荐了个"老年版数独" app, 我装上发现:

启动 50MB → 看 5 秒开屏广告 → 弹窗引导登录注册 → 关掉弹窗后还要看一次

激励视频才能进入游戏。

我就想: 安安静静做道题而已, 真的需要这么复杂吗?

试了几个网页版, 体验更糟: 有的塞满 emoji 干扰你的判断, 有的计时器一进游戏就开始跑,

我点错一下就丢 5 秒。

于是我决定自己撸一个。

image.png

一、单文件 70KB · 0 依赖

第一个问题: 用什么技术栈?

  • React / Vue 太重, 70KB 的游戏塞个框架不合理

  • npm install 安装半小时, 部署上线再打包, 杀鸡用牛刀

  • 所以最终方案: 纯 HTML5 + CSS3 + Vanilla JS, 单文件 70KB, 打开就能玩

部署到 GitHub Pages 之后, 用户在浏览器打开就能玩;

如果浏览器支持 PWA, "添加到主屏幕" 后跟原生 APP 没区别;

想装 Android 的可以从 GitHub Release 拉 66KB 的 APK。

整个仓库就一个 HTML 文件 + 一个 manifest.json + 两个图标 + 一个 LICENSE,

没有任何构建工具, 没有任何 npm 依赖。

完整仓库: github.com/GZTai/one-t…

image.png

二、数独生成器: 怎么保证每盘都有唯一解

数独看似简单, 真做起来才发现坑多。

第一版我直接挖洞: 生成完整数独 → 随机挖掉 30 个格子。结果发现

很多盘有多个解, 用户做到一半发现"咦我填的也是对的", 体验直接裂开。

于是加了唯一解校验:

function countSolutions(board, cap = 2) {
// 回溯找解, 找到 cap 个就停, 不求全解
// 关键: cap = 2 比 cap = ∞ 快 100 倍
const empty = findEmpty(board);
if (!empty) return 1;
let count = 0;
for (const n of candidates(board, empty)) {
board[empty] = n;
count += countSolutions(board, cap);
if (count >= cap) {
board[empty] = 0;
return count;
}
}
board[empty] = 0;
return count;
} 

判断逻辑: 挖洞后跑一遍校验, 如果解数 > 1, 就把那个格子补回去。
这样能保证用户拿到的盘永远有且仅有一个解。

难度梯度怎么选提示数: 我做了一轮测试, 结论是:

  • 45 个提示: 入坑级 (新手秒做)
  • 38 个提示: 杂志级 (周末填填报纸那种)
  • 32 个提示: App 中等难度
  • 28 个提示: App 高级
  • 25 个提示: App 大师 (硬核玩家上限)

踩坑警告: 17 个提示是数学极限, 我尝试过做到 17 提示 + 唯一解,
1000 盘里只有 3 盘能达到, 不适合做工程方案, 别瞎试。

image.png

三、移动端适配的 7 个细节

1. touch-action: manipulation —— 砍掉 300ms 延迟

移动浏览器为了"双击放大"会在第一次点击后等待 300ms,
加上这个 CSS 后浏览器知道这是个 web app, 直接响应:

.board-cell { touch-action: manipulation; }
  1. env(safe-area-inset-*) —— 兼容 iPhone 刘海

iPhone X 之后底部有条黑线, 不处理的话会被遮:

.app {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
}
  1. 100dvh —— 解决浏览器底栏跳动

100vh 在 Safari 上会算上底栏高度, 用户来回滑会跳。
改用 100dvh (dynamic viewport height) 后跟随实际可视区:

.app { height: 100dvh; }
  1. 数字单元格高亮: ::before + z-index:3

需求: 选中的格子要整个高亮, 但数字必须清晰可见。
错误做法: 数字和高亮同层, 高亮会盖住数字。
正确做法: 高亮用 ::before 伪元素 + z-index:2, 数字本身 z-index:3, 永远不被遮:

.cell { position: relative; }
.cell::before {
content: '';
position: absolute;
inset: 0;
background: var(--highlight);
z-index: 2;
}
.cell .num {
position: relative;
z-index: 3;
}
  1. 大写 PAUSE 居中遮罩

需求: 暂停时屏幕中央一个大写 "PAUSE", 用户绝对不漏看。

.pause-overlay {
position: fixed;
inset: 0;
display: grid;
place-items: center;
font-size: 8rem;
font-weight: 900;
text-transform: uppercase;
color: var(--accent);
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
}
  1. GO 启动屏: 防误计时

需求: 用户点开游戏 → 看到盘面 → 不要立刻开始计时。

我加了个 GO 启动屏, 用户必须主动点 GO 才计时。这避免了
"我刚进游戏还没看清就被计时 5 秒了" 的常见痛点。

  1. addEventListener 在 Android 16 WebView 不可靠

这是这次踩的最大坑:

*// ❌ 经常丢点击*
**button.addEventListener('click', handler);

解决: 改用 HTML 内联 onclick, 浏览器解析 HTML 时最高优先级:

<button onclick="window.dxStartGame()">GO</button>

实测稳定 100% 触发, 不会再丢点击。

image.png

四、Android 16 手动出 APK 踩坑全记录

数独跑通后我想到: Android 用户也想装一个啊, 但 PWA 在 Android 上
"添加到主屏幕" 体验还是不如原生, 不如手动出 APK。

第一步: 准备环境

apt install -y default-jdk-headless
*# 安装 Android cmdline-tools + platform-34 + build-tools-34.0.0*

第二步: 7 步出 APK

*# 1. 编译资源*
aapt2 compile --dir res -o compiled.zip
*# 2. 链接资源 + 生成 base.apk*
aapt2 link -I android.jar --manifest AndroidManifest.xml \
-R compiled.zip --auto-add-overlay --java gen -o base.apk
*# 3. 编译 Java (注意: 必须 source/target 1.8, 不能 11)*
javac -source 1.8 -target 1.8 -bootclasspath android.jar \
-d classes MainActivity.java R.java
*# 4. 生成 dex*
d8 --output bin --lib android.jar classes/...
*# 5. 关键: 把 dex 复制到根目录*
Agent: main | Model: MiniMax-M3 | Provider: minimax
cp bin/classes.dex classes.dex
*#6. 把 dex + 资源加进 APK
aapt add base.apk classes.dex  
aapt add base.apk assets/index.html
*#7. 对齐 + 签名
zipalign -f 4 base.apk aligned.apk  
apksigner sign --ks debug.keystore  
--ks-pass pass:android --key-pass pass:android  
--ks-key-alias androiddebugkey  
--out final.apk aligned.apk

踩坑 1: bin/classes.dex 路径

第一次我按 Google 官方教程, 用 d8 --output bin/ + aapt add base.apk bin/classes.dex。

结果 Android 16 装不上, 提示"无效或不兼容"。

排查 3 天, unzip -l base.apk 才发现 dex 路径是 bin/classes.dex,

Android 安装器只认根目录的 classes.dex。

修复: 加一行 cp bin/classes.dex classes.dex, 用根目录的 dex 加进去。

踩坑 2: 外部存储权限

第二版我想: 数独用 localStorage 存排行榜, 那是不是应该申请

READ_EXTERNAL_STORAGE / WRITE_EXTERNAL_STORAGE?

实测 Android 16 拒装, 提示"无效或不兼容"。

原因: Android 16 对外部存储权限 + requestLegacyExternalStorage="true"

严格拦截。

正确做法: WebView 应用的 localStorage 在应用沙箱内

(/data/data//app_webview/), Android 4.4+ 根本不需要任何

外部存储权限。隐私协议里写明"数据仅存于本机沙箱"即可。

踩坑 3: 排错流程

  • unzip -l xxx.apk 查看 APK 内 dex 路径, 必须是 classes.dex

  • aapt dump badging xxx.apk 查看权限声明, 不能有外部存储

  • 拒装提示"无效或不兼容" → 先看 APK 内部结构, 不要瞎猜

image.png

五、清单

做这个项目大概花了两个月零碎时间, 跨过年假和几个周末。

最终 deliverable:

  • 1 个 HTML 文件 (~2000 行, 70KB)

  • 1 个 manifest.json (PWA 配置)

  • 2 个图标 (192/512)

  • 1 个 APK (66KB)

  • 0 个外部依赖

GitHub: github.com/GZTai/one-t…

在线玩: gztai.github.io/one-tap-sud…

APK: github.com/GZTai/one-t…

求个 star, 有 bug / 新功能需求提 issue, 我慢慢加。

你们做单文件 Web 应用都遇到过什么坑? 评论区聊聊。