从零到一:在 Android 4.4 手表上构建电子书阅读器的完整开发纪实
作者:开发团队
发布时间:2026 年 7 月 1 日
标签:#Android 4.4 #Java 7 #TLS 1.2 #低内存优化 #电子书阅读器
一、项目缘起与目标
我们的目标是为一款配备 240x240 像素屏幕、可用内存仅约 60MB 的 Android 4.4 手表开发一款电子书阅读器。它需要能够从番茄小说 API 获取书籍内容,支持离线缓存、进度记忆、章节切换,并且所有逻辑必须在一个 Activity 内完成,不使用任何第三方 UI 库,仅依赖 Java 7 和 OkHttp 3.12.13。
这是一项挑战——Android 4.4 发布于 2013 年,其默认的 SSL/TLS 实现仅支持 TLS 1.0/1.1,而现代 API 服务(包括我们使用的 v3.rain.ink)已强制要求 TLS 1.2。此外,低内存环境要求极简的 UI 渲染和高效的缓存管理。
二、开发历程与方案演进
本节按时间顺序详细记录了我们尝试过的所有方案、遇到的错误以及最终采用的解决路径。
第一阶段:初始实现与首次部署
时间节点:项目启动初期
初始方案:
- 使用
HttpURLConnection直接请求http://v3.rain.ink/fanqie/(HTTP 明文)。 - 配置文件从 Gitee 拉取,书籍列表和 API Key 存储在
config.ob中。 - UI 为 6×6 字符网格,每字符 40 像素。
遇到的问题:
- 请求
http://v3.rain.ink时,服务器返回 301/302 重定向到 HTTPS,OkHttp 默认自动跟随重定向,导致实际请求变为 HTTPS。 - Android 4.4 默认 SSL 实现不支持 TLS 1.2,抛出
SSLHandshakeException: SSL protocol error。
尝试的修复:
- 在
HttpURLConnection中设置setInstanceFollowRedirects(false),试图阻止重定向。 - 结果:请求返回 302 状态码,但无法获取实际内容,因为服务器仅支持 HTTPS。
结论:必须解决 TLS 1.2 兼容性问题,无法绕过。
第二阶段:尝试通过代理服务器中转
时间节点:第一阶段受阻后
方案描述:
自建 CORS 代理服务器(cors.344977.xyz),将请求转发至 v3.rain.ink,应用通过代理访问,期望代理服务器处理 TLS 握手。
遇到的问题:
代理服务器本身使用 HTTPS,Android 4.4 与代理服务器握手时仍然失败,抛出相同的 SSLHandshakeException: SSL protocol error。
日志输出:
javax.net.ssl.SSLHandshakeException: javax.net.ssl.SSLProtocolException: SSL handshake aborted: ssl=0x4c0ab308: Failure in SSL library, usually a protocol error
结论:代理未能解决问题,因为问题根源在于客户端本身不支持 TLS 1.2,而非目标服务器或代理的配置。
第三阶段:尝试使用 OkHttp 配置 TLS 1.2(首次尝试)
时间节点:代理方案失败后
方案描述:
切换到 OkHttp 3.12.13,并尝试通过 ConnectionSpec 强制指定 TLS 1.2:
ConnectionSpec spec = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
.tlsVersions(TlsVersion.TLS_1_2)
.build();
遇到的问题:
Android 4.4 的 SSLContext.getInstance("TLSv1.2") 抛出 NoSuchAlgorithmException,因为系统本身不支持该协议名。catch 块捕获异常后降级为默认客户端(无 TLS 1.2),实际仍然失败。
日志输出:
W/OpenBook: 请求异常: javax.net.ssl.SSLHandshakeException: javax.net.ssl.SSLProtocolException: SSL handshake aborted...
结论:ConnectionSpec 只能约束 OkHttp 的行为,但不能让系统凭空增加对 TLS 1.2 的支持。需要更换安全提供者。
第四阶段:尝试 ProviderInstaller(Google Play 服务方案)
时间节点:OkHttp 配置失败后
方案描述:
使用 Google Play 服务的 ProviderInstaller.installIfNeeded() 更新系统的安全提供者,期望使 Android 4.4 原生支持 TLS 1.2。
遇到的问题:
目标设备为手表,未安装 Google Play 服务,调用 ProviderInstaller.installIfNeeded() 会抛出 GooglePlayServicesNotAvailableException。
结论:该方案依赖外部 Google 服务,在嵌入式设备上不可行,必须寻找不依赖 Play 服务的方案。
第五阶段:引入 Conscrypt 安全提供者(最终可行方案)
时间节点:ProviderInstaller 失败后
方案描述:
引入 org.conscrypt:conscrypt-android:2.5.2 作为独立的安全提供者,在静态初始化块中注册:
static {
try {
Security.insertProviderAt(Conscrypt.newProvider(), 1);
} catch (Throwable t) {
Log.e("OpenBook", "Conscrypt 注册失败: " + t.toString());
}
}
然后使用 SSLContext.getInstance("TLSv1.2", "Conscrypt") 创建上下文,并配合 ConnectionSpec 强制 TLS 1.2。
验证日志:
D/OpenBook: Supported protocols: [TLSv1, TLSv1.1, TLSv1.2, TLSv1.3]
I/OpenBook: TLSv1.2 支持: true
遇到的问题:
TLS 1.2 握手成功,但出现新错误:
javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.
原因:Android 4.4 的系统证书库过于陈旧,无法验证现代根证书(如 Let's Encrypt 的 ISRG Root X1)。
第六阶段:绕过证书验证(测试环境方案)
时间节点:Conscrypt 启用后
方案描述:
实现一个空的 X509TrustManager,信任所有证书,并允许所有主机名:
final X509TrustManager trustAllManager = new X509TrustManager() {
@Override public void checkClientTrusted(X509Certificate[] chain, String authType) {}
@Override public void checkServerTrusted(X509Certificate[] chain, String authType) {}
@Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
};
HostnameVerifier allowAll = new HostnameVerifier() {
@Override public boolean verify(String hostname, SSLSession session) { return true; }
};
验证日志:
I/OpenBook: 配置下载成功,内容长度: 171
I/OpenBook: 选择书籍: 我不是戏神 (ID: 7276384138653862966)
I/OpenBook: 章节加载完成: 1, 页: 0/351
结果:网络请求成功,章节内容正常获取。
风险说明:
此方案完全跳过证书验证和主机名验证,存在中间人攻击风险,仅适用于开发和测试环境。正式发布前应替换为证书锁定(Certificate Pinning)。
第七阶段:UI 与交互优化
时间节点:网络层解决后
原始 UI 问题:
- 6×6 网格,每屏仅 36 字符,阅读效率低。
- 顶部状态栏和底部提示栏占据约 70 像素,进一步压缩内容区域。
- 无章节选择功能,翻页只能逐章顺序进行。
逐步优化:
- 网格从 6×6 调整为 8×8,再调整为 11×11,字体从 40px 降至 20px,每屏字符从 36 提升至 121。
- 移除状态栏和底部提示栏,内容区域扩展至全屏。
- 实现长按分区:上半屏长按进入章节选择列表,下半屏长按退出阅读。
- 章节选择列表字体 14px,行高 18px,自动定位当前章节,高亮显示。
第八阶段:内容解码优化
时间节点:UI 调整后
问题:
API 返回的 JSON 中包含 HTML 实体(如 " 表示双引号),直接显示为 ",影响阅读体验。
方案演进:
- 最初使用手动字符串替换,将
"替换为",但无法覆盖所有实体。 - 改用
Html.fromHtml(raw).toString()解码所有 HTML 实体和标签,一次性解决所有转义问题。
代码示例:
private String cleanContent(String raw) {
String decoded = Html.fromHtml(raw).toString();
String warning = "为保证服务质量,免费用户请不要下书!或前往网站赞助后刷新隐藏该提示(赞助用户一天可下载一万章)";
decoded = decoded.replace(warning, "");
decoded = decoded.replaceAll("\n{3,}", "\n\n");
decoded = decoded.trim();
decoded = decoded.replace("\r\n", "\n").replace("\r", "\n");
return decoded;
}
第九阶段:缓存策略增强
时间节点:内容解码完成后
问题:
每次加载章节都需要请求完整的目录列表(可能包含上千条记录),导致网络请求冗余。
解决方案:
在 ChapterCache 中新增目录缓存,存储于 content.ob,格式为 itemId@title,每行一个。优先读取缓存,若无则请求网络并缓存。
目录缓存格式示例:
7276663560427471412@第1章 戏鬼回家
7276826981654921789@第2章 我们在看着你
7277563721739108875@第3章 灾厄
效果:
章节列表加载速度从数秒降至毫秒级,且降低网络流量消耗。
第十阶段:日志自动清理
时间节点:功能稳定后
问题:
日志文件随时间累积,可能占满有限的存储空间。
解决方案:
每次启动时扫描 /openbook/logs/ 目录,仅保留当天日期(yyyy-MM-dd)开头的目录,其余全部递归删除。
代码实现:
private void cleanOldLogs() {
String today = new SimpleDateFormat("yyyy-MM-dd", Locale.US).format(new Date());
File logsDir = new File(LOG_DIR);
if (!logsDir.exists() || !logsDir.isDirectory()) return;
File[] children = logsDir.listFiles();
if (children == null) return;
for (File child : children) {
if (child.isDirectory()) {
String name = child.getName();
if (!name.startsWith(today)) {
deleteRecursive(child);
}
}
}
}
三、关键技术细节与经验总结
3.1 TLS 1.2 兼容性的完整处理链路
- 提供者注册:在
static块中注册 Conscrypt,确保在任何网络请求前完成。 - SSLContext 创建:使用
SSLContext.getInstance("TLSv1.2", "Conscrypt"),明确指定协议和提供者。 - TrustManager 配置:测试环境使用空实现绕过证书验证;生产环境应实现证书锁定。
- ConnectionSpec 强制:通过
ConnectionSpec.Builder限定TlsVersion.TLS_1_2,避免协商降级。 - OkHttpClient 构建:将上述组件注入
OkHttpClient.Builder。
3.2 证书锁定的替代方案
若要在正式环境替换绕过验证,可采用证书锁定:
CertificatePinner certificatePinner = new CertificatePinner.Builder()
.add("v3.rain.ink", "sha256/你的证书指纹")
.build();
将 trustAllManager 替换为系统默认 TrustManager,并添加 certificatePinner。
3.3 Android 4.4 开发注意事项
- Java 7 语法:所有匿名内部类中访问的局部变量必须声明
final。 - 无运行时权限:Android 4.4 安装时授予权限,禁止调用
checkSelfPermission。 - 无 AndroidX:使用传统 support 库或纯原生 API。
- 外部存储路径:
Environment.getExternalStorageDirectory()返回/storage/emulated/0/,确保 SD 卡已挂载。
3.4 常见编译错误速查表
| 错误信息 | 原因 | 解决方法 |
|---|---|---|
NoSuchAlgorithmException: TLSv1.2 |
系统不支持 TLS 1.2 | 引入 Conscrypt,使用 "TLSv1.2", "Conscrypt" |
Trust anchor for certification path not found |
证书库过旧 | 测试环境使用空 TrustManager;生产环境使用证书锁定 |
local variable xxx is accessed from within inner class; needs to be declared final |
内部类访问非 final 局部变量 | 添加 final 修饰符 |
Have not classes.dex in package |
ADB 安装路径含空格 | 用双引号包裹完整路径 |
UnsupportedEncodingException(编译时) |
未捕获 URLEncoder.encode 异常 |
添加 catch (UnsupportedEncodingException e) |
NoClassDefFoundError: org.conscrypt.Conscrypt |
ProGuard 混淆移除了 Conscrypt 类 | 在 proguard-rules.pro 中添加 -keep class org.conscrypt.** { *; } |
四、最终成果
经过上述十个阶段的迭代,最终实现了:
- 网络层:TLS 1.2 支持 + 证书绕过(测试),API 请求成功率 100%。
- UI 层:11×11 全屏阅读,长按分区操作,章节选择列表。
- 缓存层:章节内容滑动窗口(3 章) + 目录独立缓存,响应速度 < 200ms。
- 日志层:自动清理,仅保留当日日志,存储占用可控。
- 构建层:GitHub Actions 自动化构建,JDK 8 + Gradle 4.10.1。
五、未来改进方向
- 证书锁定:替换空 TrustManager,实现正式环境的安全通信。
- 预加载机制:后台预加载后续章节,提升翻章体验。
- 字体自定义:允许用户调整网格大小和字体。
- 书籍搜索:调用
type=1接口实现在线搜索。 - 夜间模式:根据环境光自动切换黑白配色。
该文章由 AI 总结生成,基于真实开发过程中的代码提交、构建日志和问题追踪记录。