从零到一:在 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 渲染和高效的缓存管理。


二、开发历程与方案演进

本节按时间顺序详细记录了我们尝试过的所有方案、遇到的错误以及最终采用的解决路径。

第一阶段:初始实现与首次部署

时间节点:项目启动初期

初始方案

遇到的问题

  1. 请求 http://v3.rain.ink 时,服务器返回 301/302 重定向到 HTTPS,OkHttp 默认自动跟随重定向,导致实际请求变为 HTTPS。
  2. Android 4.4 默认 SSL 实现不支持 TLS 1.2,抛出 SSLHandshakeException: SSL protocol error

尝试的修复

结论:必须解决 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 问题

逐步优化

  1. 网格从 6×6 调整为 8×8,再调整为 11×11,字体从 40px 降至 20px,每屏字符从 36 提升至 121。
  2. 移除状态栏和底部提示栏,内容区域扩展至全屏。
  3. 实现长按分区:上半屏长按进入章节选择列表,下半屏长按退出阅读。
  4. 章节选择列表字体 14px,行高 18px,自动定位当前章节,高亮显示。

第八阶段:内容解码优化

时间节点:UI 调整后

问题
API 返回的 JSON 中包含 HTML 实体(如 " 表示双引号),直接显示为 ",影响阅读体验。

方案演进

  1. 最初使用手动字符串替换,将 " 替换为 ",但无法覆盖所有实体。
  2. 改用 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 兼容性的完整处理链路

  1. 提供者注册:在 static 块中注册 Conscrypt,确保在任何网络请求前完成。
  2. SSLContext 创建:使用 SSLContext.getInstance("TLSv1.2", "Conscrypt"),明确指定协议和提供者。
  3. TrustManager 配置:测试环境使用空实现绕过证书验证;生产环境应实现证书锁定。
  4. ConnectionSpec 强制:通过 ConnectionSpec.Builder 限定 TlsVersion.TLS_1_2,避免协商降级。
  5. OkHttpClient 构建:将上述组件注入 OkHttpClient.Builder

3.2 证书锁定的替代方案

若要在正式环境替换绕过验证,可采用证书锁定:

CertificatePinner certificatePinner = new CertificatePinner.Builder()
        .add("v3.rain.ink", "sha256/你的证书指纹")
        .build();

trustAllManager 替换为系统默认 TrustManager,并添加 certificatePinner

3.3 Android 4.4 开发注意事项

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.** { *; }

四、最终成果

经过上述十个阶段的迭代,最终实现了:


五、未来改进方向

  1. 证书锁定:替换空 TrustManager,实现正式环境的安全通信。
  2. 预加载机制:后台预加载后续章节,提升翻章体验。
  3. 字体自定义:允许用户调整网格大小和字体。
  4. 书籍搜索:调用 type=1 接口实现在线搜索。
  5. 夜间模式:根据环境光自动切换黑白配色。

该文章由 AI 总结生成,基于真实开发过程中的代码提交、构建日志和问题追踪记录。

转载请注明出处