Awaken-基于Hybrid方案和WebDAV的全平台开源阅读软件

少女dtysky

世界Create

时刻2022.12.27

在本篇文章中,我将从技术选型开始,分享我在开发这个阅读软件过程中的一些经验和心得,也算是对个人技术道路的又一个路标。

前言

今年六月,亚马逊宣布Kindle将在次年退出中国市场,听到这个新闻的我开始寻找它的替代品,同时由于在思考后彻底失去了对内容平台的信任,我对这个替代品定了以下要求:

  1. 存储不依赖于平台。
  2. 能够跨平台和设备共享,要求支持桌面/安卓/iOS全端。
  3. 允许笔记和进度同步。
  4. 笔记协议开放,能够从Kindle导入。

带着这些要求寻觅了许久后,我并未找到完全满足的软件。作为一个合格的程序员,我便自然有了自己去实现它的想法,这同时也可以作为我的最终项目《Project Journey》预热。但由于事情太多同时离Kindle完全停止运营为时尚早,我并未立即开始这个项目,而再次启动它的契机,则是九月份的另一个项目。

在延续创作完去年的独立游戏《Project Tomorrow》的第二版剧本后,我陷入了美术的困境,为了避免一开始就把事情搞砸,我启动了一个另一个比较小的博客改版项目《Project Totem》来磨合和美术的相性,但很遗憾最终失败了(题外话也因此我打消了相当的找个美术对象、或者说合作伙伴对象的想法)。但无论现状如何,项目总要继续推进,于是我最终决定提高自己的艺术修养,让自己继程序、剧本后也成为美术总监把控全局,便于之后找外包。而这个设想前提则离不开读书,所以正好借这个机会完成这个读书软件。

我知道以上思维路径看起来很离谱,但我确实是这么想的。

从九月底开始,我便启动了这个命名为《Awaken》的项目,花了近两个月的业余时间,我终于将其完成。

项目开源在此:

https://github.com/dtysky/Awaken

功能介绍在此:

Awaken-开源跨平台多端同步阅读软件

技术选型

经过这么多年的开发,我逐渐明白了技术选型的重要性。看起来轻松的方案最后可能会踩更多的坑,而看起来麻烦的方案可能却是弯路最少的,反而会达到稳定和效率的平衡。技术选型的前提是需求,而由前言中提到的需求可得最影响决策的两个需求是——“同步”和“全平台”。

“同步”决定了C/S架构,继而需要考虑服务端存储和多端同步方案;而“全平台”则决定了客户端需要一种跨端方案。

服务端

一般来讲,服务端有两种选择:

  1. 像常见平台那样,服务端除了存储还接管同步逻辑,书库一开始就存在于服务端,用户只是去获取并将其拉取到客户端,而笔记、进度等也都由服务端处理同步后下发。
  2. 服务端只做存储,所有的同步逻辑交由客户端,书籍由客户端添加上传,笔记、进度也都在客户端运算。

第一种方案的优势是逻辑中心化,清晰简单,要处理的状况少,但代价就是需要一个独立的服务端,而且书库的维护可能要另外写逻辑。比较适合大平台,但不适合个人维护,尤其在国内这种动辄要备案的环境更是巨麻烦。

第二种方案的优势就是只需要一个远端存储方案,而代价就是逻辑是分布式的,状况比较多且复杂。这种方案也比较适合个人用户。

综合权衡后我选择了第二种方案,那么接下来的问题只有一个了——如何选择存储方案。这个没什么说的,我选了WebDAV

WebDAV (Web-based Distributed Authoring and Versioning) 一种基于 HTTP 1.1协议的通信协议。它扩展了HTTP 1.1,在GET、POST、HEAD等几个HTTP标准方法以外添加了一些新的方法,使应用程序可对Web Server直接读写,并支持写文件锁定(Locking)及解锁(Unlock),还可以支持文件的版本控制。

目前国外支持WebDAV的网盘有不少,国内的话就坚果云吧,我也一直用的这个。

客户端

客户端的选型要比服务端复杂一些,不同于服务端只负责存储,客户端要负责整个软件所有的逻辑。倘若只是单个平台还没啥,但一旦涉及到跨端就麻烦了,主要考虑最终效果和开发成本两点:

  1. 不同平台分别实现,优点是最终性能肯定好上限高,代价是承受不起的开发成本。
  2. Flutter,移动端效果应该不错,桌面端残废,开发效率中等吧。
  3. ReactNative,emmm...不想再踩一次坑,桌面端也差不多残废。
  4. Hybrid分层方案,在Webview跑的JS前端 + 客户端通过JSB/XHR拦截实现的后端,优点是能充分利用JS生态,代价是效果受到Webview约束,仍然要实现不同客户端的JSB拦截。

其实不用再多分析大家也能看出来了,综合效果和开发成本,只有第四种方案是可行的:Hybrid本身就是一种业界早已成熟的方案,没有太多坑,不会出现原理上难以解决的工程问题,而且能充分利用JS生态也省去了很多功夫,我是没兴趣为了这么个次要项目重新造一些轮子。

开发流程

在具体的实现前,需要先确定整个开发测试和构建的流程。

如图,这里我按照一个过时前端老人的习惯,选择了typescript作为主要开发语言,webpack作为构建工具,使用dev-server做为开发服务器,发布时稍微改动做下打包就行。同时为了在开发阶段测试webdav协议,我写了webdav.server.js在本地开了个服务。

代码本体在src下,interfaces中包括后端接口协议和书籍同步接口协议的定义,backend中是不同平台下后端的具体实现,frontend最后则是具体的前端逻辑。

platforms是不同平台下的项目工程。在开发阶段,我用dev-server开个支持hot-load的本地服务器,以不同平台的Webview打开本地Url来方便调试;在发布阶段,我将产物构建到三个平台工程的指定目录下,再以后续会提到的手段加载。

test中是提供测试的一些书籍和Kindle导出的笔记。

后端实现

客户端的后端部分主要负责通过一致的接口协议,将Native基础能力暴露给Webview前端使用。

在代码接口上,我在src/interfaces/IWorker中定义了接口协议,并在src/backend中在各端具体实现,同时在``

接口协议

分层设计最重要的是接口协议,我这里依照项目需求,设计了以下接口:

export type TBaseDir = 'Books' | 'Settings' | 'None';
export type TToastType = 'info' | 'warning' | 'error';

export interface IFileSystem {
  readFile(filePath: string, encoding: 'utf8' | 'binary', baseDir: TBaseDir): Promise<string | ArrayBuffer>;
  writeFile(filePath: string, content: string | ArrayBuffer, baseDir: TBaseDir): Promise<void>;
  removeFile(filePath: string, baseDir: TBaseDir): Promise<void>;
  readDir(dirPath: string, baseDir: TBaseDir): Promise<{path: string, isDir: boolean}[]>;
  createDir(dirPath: string, baseDir: TBaseDir): Promise<void>;
  removeDir(dirPath: string, baseDir: TBaseDir): Promise<void>;
  exists(filePath: string, baseDir: TBaseDir): Promise<boolean>;
}

export interface IWorker {
  fs: IFileSystem;
  loadSettings(): Promise<ISystemSettings>;
  saveSettings(settings: ISystemSettings): Promise<void>;
  selectFolder(): Promise<string>;
  selectBook(): Promise<string[]>;
  selectNote(): Promise<string[]>;
  showMessage(msg: string, type: TToastType, title?: string): Promise<void>;
  setBackground(r: number, g: number, b: number): Promise<void>;
  onAppHide(callback: () => void): void;
  getCoverUrl(book: IBook): Promise<string>;
}

接口的方法名都很明显了,不做过多解释。接下来要做的就是在各端实现这个IWorker接口。

桌面端

在桌面端,基于浏览器的方案有不少,比如最广为人知的Electron,还有类似的CEF等,其基本都是打包了一个Chromium进去,在开发简单、一致性强、兼容性强等优点下,也有安装包大小和内存开销等为人诟病的代价。

一开始我是准备直接用Electron的,但由于其只支持桌面,想偷懒的我便不由自主得想:“都2022年了,这么热衷造轮子的前端业界,不会还没有能直接跨所有端的方案吧?”虽然答案仍然确实是没有,但却意外发现了一个框架——Tauri

Tauri是基于RustWebview的混合应用开发框架,其目前支持全桌面平台,并计划支持客户端(当然遥遥无期)。其优势是利用系统原生的Webview(不错桌面系统也有Webview),包体积很小并且内存开销会小一些,但相对代价就是很难利用Node生态,并且可能存在平台一致性问题,在某些低版本操作系统不支持。

经过权衡,最终我选择了Tauri,因为这个应用并不需要什么扩展逻辑,只需要基本的文件系统、提示、桌面选择器等等基本能力,而这些它都有官方支持。接下来,就让我们看看怎么在桌面端实现这个接口。

FileSystem

首先是接口中的文件系统,看到方法名便可以知道它们本质上就是对本地文件的存取。这个在Tauri中很简单,其官方提供的库@tauri-apps/api中就有相关能力,只需要将其引入并在tauri.conf.json中的tauri.allowlist中配置好fs的参数即可使用,比如:

import {fs} from '@tauri-apps/api';

async readFile(filePath: string, encoding: 'utf8' | 'binary', baseDir: TBaseDir) {
  const {fp, base} = processPath(filePath, baseDir);

  return encoding === 'utf8' ?
    fs.readTextFile(fp, base && {dir: base}) as Promise<string> :
    (await fs.readBinaryFile(fp, base && {dir: base})).buffer;
}

注意到baseDir这个参数,他指定了当前操作路径相对的目录,这里我用TBaseDir指定,Books表示用户指定的书籍目录,Settings 则是用户个人配置目录,None代表传入绝对路径。当然这些目录在不同平台下的表现不一致,在桌面端由于能够允许用户自己指定文件夹,所以Books是用于配置的,Settings则是appDir

其他接口

文件系统之外就是其他接口了,其中:

  1. loadSettingssaveSettings只是存取Settings/settings.json文件。
  2. selectFolderselectBookselectNoteshowMessage都可以用@tauri-apps/api中的dialog模块解决,前三个是dialog.open,最后一个是dialog.message
  3. setBackgroundonAppHide在桌面端是不必要的。
  4. getCoverUrl本质上是一种优化,这个会在前端部分说明。

安卓端

不同于桌面端Tauri帮我们搞定了大部分事情,移动端就要麻烦不少,安卓和iOS要去分别手动实现JS到客户端的绑定,不过好在如开头所说这个选型是比较稳健的,踩了点坑还算顺利。

安卓的我用的是Kotlin,写起来没Java那么啰嗦,它的Webview用起来是比较简单的,JSBridge通过addJavascriptInterface方法配合@JavascriptInterface注解即可添加:

// 定义JSB
class AwakenJSB {
  @JavascriptInterface
  fun setBackground(r: Double, g: Double, b: Double) {
    ......
  }
}

// 注册JSB
webView?.addJavascriptInterface(jsb!!,"Awaken")

很简单是吧?那么看起来我们只要通过这个JSB实现下文件之类的接口,也没多麻烦的样子?一开始我也是这么想的,但做起来后却发现没这么简单。

文件系统

JSB有个很大的问题是它只能传输基本类型,也就是数字、字符串之类的,而不能传输二进制数据。但对于这个软件来说,电子书和封面都是二进制数据,如果全部都走JSB的话,只有一招——在一端把二进制数据转base64,到了另一端再转回来。

虽然理论上这没啥问题,对于绝大部分书籍而言(1M以内)转换的开销对于客户端或者V8 JIT加持下的JS绰绰有余,但每次都这么转一下对于我而言是难以接受的——即使到现在,我还是有那么一些完美主义倾向。

那么是否存在一种方式,能够在双端不经转换地传输二进制数据呢?仔细想想,“由前端和其他端互相传输二进制数据”,这不就是XHR或者说fetch吗?至此,我的思路便从“如何用JSB传输二进制数据”变成了“如何拦截XHR”。

在稍许调研后,我便找到了安卓Webview提供的官方拦截方法:

webView.webViewClient = object: WebViewClient() {
  override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
    ......
  }
}

我们可以从request最后拿到请求的url,然后拆分出hostpathquery,进入下一步操作:

if (url.host.equals("awaken.api")) {
    var method: String = url.path.toString().substring(1)
    var params: MutableMap<String, String> = mutableMapOf(
        "method" to request.method
    )
    url.queryParameterNames.forEach {
        params[it] = url.getQueryParameter(it).toString()
    }

    return jsb!!.callMethod(method, params, requestHeaders)
}

这里我以awaken.api作为此类特殊接口的host标识,以path作为请求的方法名,以query传递参数,一次接口调用转换为请求如下:

fetch(`http://awaken.api/${method}?${query}`, {
  method: data ? 'POST' : 'GET',
  body: data
});

识别到此类请求后,客户端会调用jsb示例(直接复用了上面提到的JSB类)对应的方法,进行处理:

fun callMethod(
    method: String,
    params: Map<String, String>,
    origHeaders: Map<String, String>
): WebResourceResponse {
  val headers = HashMap<String, String>()
  headers["Access-Control-Allow-Origin"] = "*"
  headers["Access-Control-Allow-Methods"] = "*"
  headers["Access-Control-Expose-Headers"] = "X-Error-Message, Content-Type, WWW-Authenticate"

  try {
    处理实际逻辑......
    return WebResourceResponse("", Charsets.UTF_8.toString(), 200, "OK", headers, stream)
  } catch (error: Exception) {
      headers["X-Error-Message"] = error.message.toString()
      return WebResourceResponse("application/json", "utf-8", 200, "Error", headers, ByteArrayInputStream(ByteArray(0)))
  }
}

这里要注意我在返回的headers中都写入了允许跨域,这实际上也是为了解决后面章节的问题,而其中的X-Error-Message这个字段是为了返回错误,至于为什么么...因为iOS中的拦截无法返回自定义状态信息,安卓也只能跟着搞了。

至于实际上的文件存取逻辑没什么好说的,查一下API就完了,唯一值得一提的点是我将用户文件都存在了context.getExternalFilesDir(null)!!.toPath()取得的扩展外部存储中。

按理说到这了,文件系统应该OK了吧?既满足了需求,又能够避免base64转换,简直完美!那我只能说太天真了。在实现过程中我很快就遇到了麻烦——我无法获取到POST请求的body。并且在深度搜索的最后,也只找到了一个社区给谷歌在19年提的Issue,他们说在考虑支持然后就...没有然后了。

所以虽然很不甘心,我最终也只能做了个特殊处理——如果是安卓平台并且是writeFile接口,还是走JSBridge,也就是说在写入的情况下,二进制数据还是要经历 encodeBase64 -> 传到客户端 -> decodeBase64 的过程,具体的逻辑就不多说了也很简单。

不过最终综合来看,从前端向客户端写入二进制数据的状况只有在添加书籍时,这在移动端是个非常低频的操作,远低于读取,而读取的优化是不受这个影响的,整体仍然很赚。

其他接口

其他接口的实现就是完全通过JSBridge了,其实也没什么好说的,简单提一下吧。

首先是showMessage这个接口,实际上就是利用了客户端的AlertDialog

// 定义
mAlertDialog = AlertDialog.Builder(mContext)
mAlertDialog.setPositiveButton("OK",
    DialogInterface.OnClickListener { dialog, which -> dialog.cancel()}
)
mAlertDialog.setNegativeButton("Close",
    DialogInterface.OnClickListener { dialog, which -> dialog.cancel()}
)

// 调用
mAlertDialog.setTitle(title)
mAlertDialog.setMessage(message)
mAlertDialog.show()

select系列就稍微有点麻烦了,实际上是实现了一个通用的selectFiles接口:

@JavascriptInterface
fun selectFiles(title: String, mimeTypes: String) {
    var res: Array<String> = arrayOf()
    mContext.selectFiles(mimeTypes) {
        res = it
        mContext.mainWebView?.evaluateJavascript(
            "Awaken_SelectFilesHandler(${JSONArray(res)})",
            ValueCallback {  }
        )
    }
}

首先注意这里的evaluateJavascript,这是因为JSBridge都是同步调用,但这里面实际上执行了一个异步操作,所以为了通知前端,我执行了一个全局的JS方法Awaken_SelectFilesHandler,而对应于前端则会在调用JSB方法前设置这个全局方法的值。

而这里之所以会调用主activity的接口然后回调,主要是因为安卓机制上的限制——唤起文件选择菜单实际上是启动另一个activity,而我们需要重写主activityonActivityResult方法,来获取结果:

fun selectFiles(
    mimeTypes: String,
    callback: (files: Array<String>) -> Unit
) {
    selectFilesCallback = callback
    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
        addCategory(Intent.CATEGORY_OPENABLE)
        type = mimeTypes
        putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
    }

    // 唤起菜单,指定状态码为4
    startActivityForResult(intent, 4)
}

// 
override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
    if (requestCode == 4) {
        // 处理状态码为4的结果
        ......
    }

    super.onActivityResult(requestCode, resultCode, resultData)
}

剩下的就是onAppHide方法可以监听主activityonPause周期,然后用evaluateJavascript执行一个全局JS方法即可;setBackground方法在安卓端并不需要,取而代之的是隐藏安卓的虚拟按键,这个本质上就是隐藏ActionBar并进入全屏模式,代码自己看吧没啥特别要注意的。

iOS端

iOS的接口思路和安卓是完全一致的,不过由于平台差异,实现起来还是有些不同。由于实在不想写OC了,这里我选择的是Swift顺便带上swiftui,同时为了JIT的性能用的是WKWebView。和安卓类似,我同样需要注册JSBridge和进行XHR拦截,这里先说JSBridge,XHR拦截就完全交给文件系统一节吧。

WKWebView的JSBridge注册也很简单,首先我们要定义一个实现了WKScriptMessageHandler协议的类AwakenJSB,然后将其实例注册即可:

// 实现JSB
public class AwakenJSB: NSObject, WKScriptMessageHandler, WKUIDelegate {
  init(onChangeBg: @escaping (_ color: CGColor) -> ()) {
    ......
  }
}


// 以下代码在`swiftui`对应实现`UIViewRepresentable`协议的`WebView`类中
// 实例化JSB
let jsb = AwakenJSB(onChangeBg: onChangeBg)

// 初始化JS脚本
initJS = """
window.Awaken = {
    getPlatform() {
        return 'IOS';
    },
    showMessage(message, type, title) {
        window.webkit.messageHandlers.showMessage.postMessage({title: title, message: message, type: type || ''});
    },
    selectFiles(title, types) {
        window.Awaken.showMessage("iOS设备不支持导入本地书籍,请使用其他平台操作", "error", "");
        window.Awaken_SelectFilesHandler([]);
    },
    setBackground(r, g, b) {
        window.webkit.messageHandlers.setBackground.postMessage({r: r, g: g, b: b});
    }
}
"""

// 初始化WebView
let config = WKWebViewConfiguration()
...一些初始化逻辑
let wkWebView = WKWebView(frame: .zero, configuration: config)

// 注入初始化脚本
config.controller.addUserScript(WKUserScript(source: initJS, injectionTime: .atDocumentStart, forMainFrameOnly: true))
// 注册JSB
config.controller.add(self, name: "showMessage")
config.controller.add(self, name: "setBackground")

onChangeBg回调这里暂时无需在意,其他除了流程和安卓大差不差,唯一有显著区别的就是initJS了,这是一段会在WKWebView加载完html、执行用户js前注入的一段js代码。可以看到其实际上给全局挂载了一些方法,而这些方法在安卓中是直接用@JavascriptInterface注解实现在JSB类中的。再看每个方法的实现,除了iOS无法实现文件选择外导致无效的selectFiles外,都有个调用window.webkit.messageHandlers.xxxx.postMessage,这是因为WKWebView只支持前端和客户端的异步通信,只能这么搞,好在这几个接口基本都不需要返回值,随便搞搞就行了。在jspostMessage后,我们还需要在客户端稍微处理下:

public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
    if (message.name == "showMessage") {
        let params = message.body as! [String: String]
        showMessage(message: params["message"]!, type: params["type"]!, title: params["title"]!)
    } else if (message.name == "setBackground") {
        let params = message.body as! [String: Double]
        setBackground(r: params["r"]!, g: params["g"]!, b: params["b"]!)
    }
}

Swift是我写过的第二啰嗦的语言,苹果的API设计不敢恭维,XCode就是依托答辩。

文件系统

iOS端的XHR拦截的方式和安卓大同小异,不过这API搞起来虽然蛋疼,但人家却支持了获取requestbody...要我说你两就不能合计合计整个完全体吗?

不吐槽了...来看看怎么搞吧,iOS提供了WKURLSchemeHandler协议来为WKWebView提供XHR拦截:

// 实现协议的类
public class AwakenXHRHandler: NSObject, WKURLSchemeHandler {
    // 请求开始
    public func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
        ......
    }

    // 请求结束
    public func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
        ......
    }
}

// WKWebView的那个`config`,注册拦截器,传入`jsb`只是为了和安卓一样复用JSB类实现逻辑
config.setURLSchemeHandler(AwakenXHRHandler(jsb: jsb), forURLScheme: "awaken")

这里特别要注意的、和安卓不同的是forURLScheme这个参数,它指定了一个请求的schema,因为理论上iOS不允许开发者拦截WKWebView的标准协议,类似http/https等等,所以这里我必须指定一个自定义的awaken,实际请求时为:

fetch(`awaken://awaken.api/${method}?${query}`, {
  method: data ? 'POST' : 'GET',
  body: data
});

不过在安卓上用自定义schema请求会报错,也是够麻烦的。

由于WKWebView允许获取拦截到的请求的body,所以也不用像安卓那样麻烦地去搞什么base64转换了:

let request = urlSchemeTask.request
guard let requestUrl = request.url else { return }
var method = requestUrl.path
method = method == "" ? method : String(method[method.index(method.startIndex, offsetBy: 1)...])
var params: [String: String] = [:]
let components = URLComponents(url: requestUrl, resolvingAgainstBaseURL: true)
let queryItems = components?.queryItems;
if (queryItems != nil) {
    params = queryItems!.reduce(into: [String: String]()) { (result, item) in
        result[item.name] = item.value
    }
}
let body = request.httpBody

取得了methodparams后,就可以正常处理请求并返回了,这个并没有什么麻烦的,感兴趣可以直接去项目看代码,唯一值得说道的下面几点:

其一,如何获取用户目录:

let paths = NSSearchPathForDirectoriesInDomains(
    .documentDirectory,
    .userDomainMask,
    true
);
mBaseDir = URL(fileURLWithPath: paths.last!, isDirectory: true)

其二,如何返回response。一般的教程中都会说返回URLResponse,但这个无法自定义状态码和headers,无法满足需求,在调研后我最终找到了HTTPURLResponse

let response = HTTPURLResponse(url: requestUrl, statusCode: 200, httpVersion: nil, headerFields: headers)

其他接口

和安卓相同,其它接口都是通过JSBridge实现的。

首先是showMessage这个接口,得益于iOS的天才API设计和每升一个版本就相当于另一门语言的swift,恕我懒得搞清楚它背后做了什么,直接用吧:

func showMessage(message: String, type: String, title: String = "") {
    let alert = UIAlertController(title: title, message: message, preferredStyle: UIAlertController.Style.alert)
    alert.addAction(UIAlertAction(title: "确定", style: UIAlertAction.Style.default, handler: nil))

    UIApplication
        .shared
        .connectedScenes
        .flatMap { ($0 as? UIWindowScene)?.windows ?? [] }
        .first { $0.isKeyWindow }?
        .rootViewController?
        .present(alert, animated: true, completion: nil)
}

然后是onAppHide,这个也很简单,我们首先要让AwakenJSB持有WKWebView实例,然后配合NotificationCenter实现:

public func setWebview(webview: WKWebView) {
    self.webview = webview
    NotificationCenter.default.addObserver(self, selector: #selector(didEnterBackground), name: UIScene.didEnterBackgroundNotification, object: nil)
}

@objc func didEnterBackground() {
    webview?.evaluateJavaScript("window.Awaken_AppHideCB && window.Awaken_AppHideCB()")
}

最后就是setBackground了,这个在安卓中无用,但对于大部分都是刘海异形屏的iPhone,还是很有必要的——我们需要将WKWebView放在Safe Area,顶部和底部保证和WebView的背景色一致,而这一点我最终的做法是利用swiftui的特性:

struct ContentView: View {
    @State var bgColor: CGColor = CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)

    var body: some View {
        ZStack() {
            Color(bgColor)
                .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
                .background(Color.red)
                .edgesIgnoringSafeArea(.all)
            WebView(
                onChangeBg: changeBgColor
            )
        }
    }

    func changeBgColor(color: CGColor) {
        bgColor = color
    }
}

在UI的根节点,我在底层放了一个全屏的Color控件来铺满背景,然后在上面放上自定义的WebView控件,并利用onChangeBg这个回调来修改控件的bgColor属性,最终影响到Color的颜色。而进一步,我们再将其传入一开始定义给AwakenJSB传入的那个onChangeBg回调,最终在JSB类中实现功能

func setBackground(r: Double, g: Double, b: Double) {
    mOnChangeBg(CGColor(srgbRed: r, green: g, blue: b, alpha: 1))
}

webdav跨域

至此,三端的后端接口都实现完毕,理论上为前端实现扫平了障碍,但在实际的开发中却还是遇到了因Web方案带来的麻烦,其中最大的一个就是跨域问题。

众所周知浏览器对于跨域资源是有CORS来限制的,这本质上是为了内容安全,无可厚非。但对于webdav这种协议,尤其是用户购买的私有服务,跨域本就不应该是障碍,而实际上很多厂商包括坚果云确实也没做这个限制。一开始我在用本地webdav-serverwebdav-client这两个开源库调试的时候,也遇到了跨域问题,但在大量搜索后认为是server的实现不标准,对其进行了魔改后凑合开发,但最终实际环境测试时仍然绕不过这个问题。为此我还专门联系了坚果云的开发人员,而他们的答复也很简单:“我们实现的是标准的WebDAV服务端协议”。

好了扯了这么久,困扰我的这个问题究竟是什么呢?其实很简单:对于非简单的跨域请求,浏览器在发送真正的请求前会先发送一个名为preflightOPTIONS请求,来保护不知晓CORS的老服务器,只有这个请求成功后才允许发出真正的请求

听起来很合理对吧?对于大多请求时没毛病,但问题在于webdav服务是有账号密码验证的,而preflight请求是不会携带验证信息的,而大多“按照标准实现”的WebDAV服务器在校验前并不会区分你是不是preflight请求...这TM就死锁了。

那怎么办呢?没啥办法,既然浏览器的限制绕不过去,就只能借由客户端了,毕竟客户端并没有CORS。如此一来,就不得不给我们上面提供的XHR拦截多加个方法了。不过这块处理起来,比上面那些接口要更复杂一些。

前端拦截

首先因为是用的开源库(我这么懒显然不想去拉一份下来自己改),所以只能看有没有hook的方案,而这时候选择Hybrid方案的优点就体现出来了——轮子多。我使用了ajax-hook库来在前端拦截XHR,将所有webdav请求都加上了一个prefix来协助拦截:

export const DAV_PREFIX = 'http://AwakenWebDav:';

proxy({
  onRequest: (config, handler) => {
    if (!config.url.startsWith(DAV_PREFIX)) {
      return handler.next(config);
    }

    const url = config.url.replace(DAV_PREFIX, '');
    ...接下来的代理操作
});

hookXHR后,接下来就是在不同平台实现请求代理了。

桌面后端

首先是桌面端,在上面接口的实现中,桌面对文件系统并不依赖与XHR拦截,所以这里要额外想怎么实现。好在Tauri官方已经给我们准备好了由rust实现并绑定好的http模块。

首先在tauri.conf.jsontauri.allowlist中配置:

"http": {
  "all": true,
  "request": true,
  "scope": [
    "https://**",
    "http://**"
  ]
}

随后在代码中简单实现即可:

// 引入
import {http} from '@tauri-apps/api';

// 实现代理
http.fetch(url, {
  method: config.method as any,
  body: config.body ? (typeof config.body === 'string' ? http.Body.text(config.body) : http.Body.bytes(config.body)) : undefined,
  headers: config.headers,
  responseType: /(png|epub)$/.test(url) ? http.ResponseType.Binary : http.ResponseType.Text
}).then(res => {
  handler.resolve({
    config: config,
    status: res.status,
    headers: {},
    response: res.data
  });
}).catch(error => {
  handler.reject(error);
});

安卓后端

移动两端做法基本一致,为前面的XHR拦截协议新增方法webdav,然后将真正请求的url作为请求的参数传入客户端即可:

if (config.body && platform === 'ANDROID') {
  const isBase64 = typeof config.body !== 'string';
  const data: string = isBase64 ? atob(config.body as ArrayBuffer) : config.body;
  jsb.setWebdavRequestBody(url, config.method, data, isBase64);
}

fetch(`${API_PREFIX}/webdav?url=${encodeURIComponent(url)}`, {
  method: config.method,
  body: config.body,
  headers: config.headers
}).then(res => {
  const errorMessage = res.headers.get('X-Error-Message');
  if (errorMessage) {
    throw new Error(`${errorMessage}: webdav(${url})`);
  }

  return (/(png|epub)$/.test(url) ? res.arrayBuffer() : res.text()).then(data => {
    handler.resolve({
      config: config,
      status: res.status,
      statusText: res.statusText,
      headers: res.headers,
      response: data
    });
  });
}).catch(error => {
  console.error(error);
  handler.reject(error);
});

但在最后的处理中双端还是有一些差异:

首先安卓端由于前面说过的原因,需要将二进制body转换为base64,所以在客户端需要实现一个JSB接口setWebdavRequestBody,在请求前将转好的base64发给客户端,这个在上面的代码也有所体现。接下来在客户端只需要将请求的url作为key,把base64存到一张Map中,后续接收到请求取出处理即可,无序赘述。

其次就是请求代理的本质是将从Web拦截下的请求由客户端发出,再将结果返回Web。而客户端发出请求时,应当带上原先请求的headersbody,很遗憾我并未在安卓的官方API找到能满足需求的接口,所以最终我使用了okhttp3这个库:

fun webdav(url: String, method: String, headers: Map<String, String>): okhttp3.Response {
    val request = okhttp3.Request.Builder().url(url)
    val cache = mWebdavRequestCache[method+url]

    if (cache == null) {
        request.method(method, null)
    } else {
        if (cache.second) {
            request.method(method, Base64.decode(cache.first, Base64.DEFAULT).toRequestBody())
        } else {
            request.method(method, cache.first.toRequestBody())
        }

        mWebdavRequestCache.remove(method+url)
    }

    headers.forEach {
        request.addHeader(it.key, it.value)
    }

    return client.newCall(request.build()).execute()
}

吐槽一下kotlingradle这些工具和依赖他们的库之间的各种依赖冲突真是蛋疼。

iOS后端

iOS这边比起安卓要更简单一些,其原生的URLRequest就可以满足需求了,在上面的XHR拦截入口前加上一个分支即可:

if (method == "webdav") {
    let url = URL(string: params["url"]!)!
    let session = URLSession.shared
    var req = URLRequest(url: url)
    req.httpMethod = request.httpMethod
    req.httpBody = body
    req.allHTTPHeaderFields = request.allHTTPHeaderFields
    let task = session.dataTask(with: req, completionHandler: {[weak self] data, response, error in
        guard let strongSelf = self else { return }
        if (error != nil) {
            strongSelf.postFailed(to: urlSchemeTask, error: error!)
        } else if (data != nil) {
            var res = (response as! HTTPURLResponse)
            var headers: [String: String] = [
                "Access-Control-Allow-Origin": "*",
                "Access-Control-Allow-Methods": "*",
                "Access-Control-Expose-Headers": "X-Error-Message, Content-Type, WWW-Authenticate"
            ]
            strongSelf.postResponse(to: urlSchemeTask, response: HTTPURLResponse(
                url: requestUrl, statusCode: res.statusCode,
                httpVersion: nil, headerFields: headers
            )!)
            strongSelf.postResponse(to: urlSchemeTask, data: data!)
            strongSelf.postFinished(to: urlSchemeTask)
        }
    })

    task.resume()
    return
}

前端实现

前端逻辑主要是书籍和笔记的管理与同步,堆业务逻辑嘛懂得都懂,无非都是说难也不难,说简单却也坑多有些麻烦。所以这里我不会说太多细节,只会捡重点说一些心得。

为了加快开发效率(其实是懒),这里我选择了以前用惯了的React作为前端框架,样式用SCSS配合css-modules,并配合17年在B站用爱发电期间和同事一起开源的hana-ui,效果还行吧。

已经两年多没写正儿八经的前端代码了,有些手生,第一次用hooks玩不太转,而且也懒得用什么状态管理库了硬莽,不得不服老啊...嘛...又不是不能用。

书籍管理

首先是书籍管理,也就是维护书籍列表,这也是软件刚进去的首页。

协议

要维护书籍,首先就需要定义好书籍的数据结构:

export type TBookType = 'EPUB';

export interface IBook {
  hash: string;
  type: TBookType;
  name: string;
  author: string;
  ts: number;
  removed?: boolean;
  cover?: string;
}

这里面最重要的是hash,它是电子书本身的md5,保证了书籍的唯一性,之所以特意算一遍hash是因为书籍本身可能重名而且也不一定都有ids元数据。在实际的存储中,我会给每本书籍创建一个以hash命名的目录,将具体内容存于其中。

type是书籍类型,之所以有这个的理由你应该猜到了...不错,一开始我想支持EPUB/PDF/MOBI等多种书籍格式,但最后发现太麻烦不值得花这么多精力就只剩EPUB了(没事毕竟我们还有针对PDF的OCR和转换神器calibre嘛...又不是不能用。

nameauthor是从电子书提取的书名和作者,没啥好说的。ts记录的是书籍被修改的(添加/删除)的时间戳,配合removed在同步时使用,毕竟咱没有中心服务器处理逻辑,为了避免同步错乱没啥办法。

最后一个cover是从书籍中提取的封面,方便首页展示。我在添加书籍时,会将封面的二进制数据提取出来存到书籍目录下,之后在每次软件启动拿到书籍信息后,会根据当前平台自动生成一个cover地址。

在桌面端,我们需要在tauri.conf.json中的tauri.protocol字段中进行配置,允许软件访问用户本地文件:

"protocol": {
  "asset": true,
  "assetScope": [
    "**"
  ]
}

之后利用接口转换地址即可:

import {tauri} from '@tauri-apps/api';

getCoverUrl(book: IBook): string {
  return tauri.convertFileSrc(`${BOOKS_FOLDER}/${book.hash}/cover.png`);
}

而在移动端就简单了,复用之前实现过的文件接口协议即可:

getCoverUrl(book: IBook): string {
  return `${API_PREFIX}/readBinaryFile?filePath=${book.hash}/cover.png&base=Books`;
}

同步

书籍列表的同步从原理上其实挺简单的,但要处理的边界情况稍微会有点麻烦,其本质上可以规约为:

拉取远端列表 -> 和本地列表对比 -> 拉取远端新增书籍 -> 上传本地新增书籍 -> 同步列表目录

具体的逻辑就不写了,有兴趣的可以自己去看代码。这里值得特别说明的有几点:

首先是书籍冲突,其原因很简单,因为本方案没有一个中心的逻辑服务器,逻辑是分布在各个设备单独处理的,同时添加本地书籍并不需要联网,这就可能导致各端分别添加了同样的书籍,然后一并同步到远端的状况,这又会带来两个可能的问题:

  1. 本地尚未上传的书籍已然存在于远端。这个问题从设计层面被解决了:因为书籍的唯一性由hash决定,一般来讲不会碰撞,所以这里不涉及到状态。
  2. 两个设备同时将本地书籍同步到远端。这个问题是切实存在的,理论上来讲可以通过webdav协议的lock方法解决,但考虑到实际会这么操作的可能性很低(喜欢在日常中COS测试工程师的用户不在其中,我也没义务考虑),所以就这么办吧,不处理。

其次是书籍的删除,同样是由于没有中心服务器的缘由,书籍的删除变得非常麻烦。我不能直接把条目移除覆盖到远端,这样会导致有两个设备都存在统一条目的情况下永远删不掉。所以我退而求其次,即便是删除了也保留条目,然后用书籍协议中的removedts字段,选择时间戳比较近的,然后判定是否被删除:

const syncToLocalBooks: IBook[] = [];
remoteBooks.forEach(book => {
  const localBook = localTable[book.hash];
  if (
    (localBook && (book.ts > localBook.ts)) ||
    (!localBook && !book.removed)
  ) {
    syncToLocalBooks.push(book);
  }
});

const syncToRemoteBooks: IBook[] = [];
books.forEach(book => {
  const remoteBook = remoteTable[book.hash];
  if (
    (remoteBook && (book.ts > remoteBook.ts)) ||
    (!remoteBook && !book.removed)
  ) {
    syncToRemoteBooks.push(book);
  }
});

最后是错误处理,由于我的设计中支持批量添加书籍,那么在中途任一环节被异常中断都是可能的。这个为了避免状况复杂化,我的选择是:直接退出,下次同步时直接重新处理,对远端直接覆盖上传。严格来说这样确实不是最优解,但本身就是低频操作,代价也还行吧。

EPUB解析阅读

在书籍列表点击任一封面后,进入的就是阅读界面了。这里我使用了epub.js这个开源库,虽然文档一般坑不少,但却是能解决绝大多数的问题,至少不用从头去再造轮子了。

这一部分吧...虽然算起来是前端最复杂的一部分,也搞了挺久,但其实也没有太多好说的,大部分看epub.js的文档就OK,值得重点提的几个地方:

首先初始化,对书籍的初始化大致逻辑如下:

const book = ePub(props.content);
rendition = book.renderTo('epub-viewer', {
  width: '100%',
  height: '100%',
  stylesheet: props.bookStyle,
  allowScriptedContent: true,
  allowPopups: true
});

这其中要注意的是allowScriptedContentallowPopups的设置,由于epub.js使用iframesandbox模式渲染,所以要启用所有功能必须开启。而stylesheet会在下面的主题切换一节说到。

其次是分页,众所周知页码是用来分割传统书籍的内容的,而EPUB这种电子媒介中并没有这个东西。对于Kindle而言,页码其实是一种额外信息,由亚马逊特别处理还原传统书籍照顾读者习惯或者方便引用。而我显然是拿不到这种信息的,所以只能按照业界一般的估计,以600字每页来分割书籍,这自然是一种不严谨的做法,但也凑合吧:

const pages = await book.locations.generate(600);

这样看起来似乎OK了,但如果只是这样,你会发现每次进入阅读都会很慢,因为生成页码是个非常耗时的操作。好在epub.js提供了一个口子,我们只需要生成一次pages,然后将其存下来,之后每次进入时读取即可:

// 存储分页数据
async savePages(book: IBook, pages: string[]) {
  return await bk.worker.fs.writeFile(`${book.hash}/pages.json`, JSON.stringify(pages), 'Books');
}

// 加载已存在的分页数据
book.locations.load(pages);

有了分页,自然就要考虑页面跳转,但这个其实没有这么简单。如果用户已经体验过阅读模式,可以知道页面的跳转有以下几种:

  1. 上一页/下一页:通过键盘左右,或者点击左右空白处。
  2. 进度条:拖动或者点击进度条,快速跳转。
  3. 目录跳转:在目录界面,点击章节标题跳转。
  4. 笔记/书签跳转:和目录类似,不过是点击笔记或书签列表。

而这些跳转,都是通过一个函数实现的:

const jump = (action: EJumpAction, cfiOrPageOrIndex?: string | number | IBookIndex) => {
  if (action !== EJumpAction.Page) {
    rendition.on('relocated', updateProgress);
  }

  if (action === EJumpAction.Pre) {
    rendition.prev();
    return;
  }

  if (action === EJumpAction.Next) {
    rendition.next();
    return;
  }

  if (action === EJumpAction.CFI) {
    // first, jump to chapter
    rendition.display(cfiOrPageOrIndex as string).then(() => {
      // then, jump to note
      rendition.display(cfiOrPageOrIndex as string);
    });
    return;
  }

  if (action === EJumpAction.Index) {
    rendition.display(idToHref[(cfiOrPageOrIndex as IBookIndex).id]);  
    return;
  }

  if (action === EJumpAction.Page) {
    rendition.display(rendition.book.locations.cfiFromLocation(cfiOrPageOrIndex as number));
    return;
  }
};

函数开头的relocated方法监听,是为了在进度跳转后,向上一级同步页数。下面就是根据不同状况做的区分处理了:

  1. PreNext:针对普通切页操作,直接使用prev()next()方法切换页面,这里不能用生成的页码是因为针对不同设备和画布,一屏显示的内容并不对应一页
  2. Index:针对目录索引,idToHref可以通过book.loaded.navigation处理得到。
  3. Page:针对通过进度条修改的页码,可见是转换到CFI处理。
  4. CFI:这个比较特别,将在下一节详细论述。

笔记、书签和进度

这一部分的逻辑算是阅读部分最为复杂的,由于需要同步,所以和上面的书籍列表同步有相似的问题,但由于需要注意顺序,状况更多更麻烦一些。

协议

首先还是要定协议,看下面两个接口:

export interface IBookNote {
  cfi: string;
  start: string;
  end: string;
  page: number;
  text?: string;
  annotation?: string;
  // timestamp
  modified: number;
  removed?: number;
}

export interface IBookConfig {
  ts: number;
  lastProgress: number;
  progress: number;
  bookmarks: IBookNote[];
  notes: IBookNote[];
  removedTs?: {[cfi: string]: number};
}

其中IBookConfig是每本书的配置文件,存于目录的config.json中:ts是更新时间戳,progress是本地进度,lastProgress是远端最新进度,这三个配合起来可以做进度同步。bookmarksnotes分别是书签和笔记列表,removedTs则是为了解决笔记的删除问题,无奈下特意的一个优化用对象。

IBookNote则是笔记或书签的数据结构:cfi/start/end这三个都是CFI下面会解释,page是根据cfi算出的页码,textannotation是笔记专有的标注的文本和用户输入的注解,而modified/removed是时间戳,用于记录当前设备删除/修改一条笔记的时间,用于同步。

CFI

上面多次提到了CFI,那么这到底是是个什么东西呢?让我们看看官方解释:

This specification, EPUB Canonical Fragment Identifier (epubcfi), defines a standardized method for referencing arbitrary content within an EPUB® Publication through the use of fragment identifiers.

简单来说,CFI或者说epubcfi,就是用于表达对EPUB电子书中某一段内容的引用。我们可以可以利用它完成对电子书中任一片段的定位索引,其形如epubcfi(/6/12!/4[3Q280-46130e5d9d644673954c13edca4fc20f]/4,/1:325,/1:340)。具体的定义我不再赘述,有兴趣可以直接看Specification,这里只讲我如何利用其完成的笔记功能。

首先是书签,在上面我们提到过relocated事件会更新进度,这个事件会返回一个location,通过其我们可以得到需要的信息:

props.onBookmarkInfo({
  start: location.start.cfi,
  end: location.end.cfi,
  cfi: mergeCFI(location.start.cfi, location.end.cfi),
  page: loc, modified: Date.now()
});

location.startlocation.end分别是当前显示内容的起始和结束CFI,我自己写了个方法mergeCFI将它们合并起来备用。而在onBookmarkInfo的处理中,我并没有直接将其作为待处理的信息直接交由书签标记逻辑,而是先用另一个方法进行了处理:

const parser = new EpubCFI();
export function checkNoteMark(notes: IBookNote[], start: string, end: string): INoteMarkStatus {
  if (!notes.length) {
    return {exist: false, index: 0};
  }

  for (let index = 0; index < notes.length; index += 1) {
    const {start: s, end: e, removed} = notes[index];
    const cse = parser.compare(start, e);
    const ces = parser.compare(end, s);

    if (ces <= 0) {
      return {exist: false, index};
    }

    if (cse <= 0) {
      return {exist: removed ? false : true, index};
    }
  }

  return {exist: false, index: notes.length};
}

这个方法传入一个书签或者笔记,返回其是否存在,以及在当前列表中的位置。为什么要做这个处理呢?因为前面说过——书签和笔记都是有序的,所以我们不能随便插入了事,要先知道插到哪个位置。而有了位置信息,接下来的逻辑也就水到渠成了。

比起书签,笔记的处理要更麻烦一些,因为其不是一个定点而是片段,同时还需要让用户自己去选中这个片段。UI层面的交互我就不多说了,无非就是堆点逻辑,其中比较重点的是如何获取选中的文字片段。如果你去查文档,它会告诉你在rendition中有个selected事件可以解决,但事实上不行,我在阅读了源码后最终只能得到一个Hack的方案——在locationChanged事件后,获取到当前的content,在其上注册事件:

rendition.on('locationChanged', () => {
  content?.off('selected', selectNote);
  const c = rendition.getContents()[0];
  c?.on('selected', selectNote);
  c !== content && setContent(c);
});

而这个事件处理器selectNote的逻辑也很简单,就是将其传入笔记标注工具组件。在这个组件中,我对每个传入的CFI判断是否为新,如果是的话使用checkNoteMark计算出其在当前笔记列表中的状态和位置,然后再计算出工具栏在页面上的位置:

const range: Range = content.range(cfi);
const {x, y, width, height} = range.getBoundingClientRect();
const cw = document.getElementById('epub-viewer').clientWidth;

setX(x % cw + width / 2);
setY(y + height / 2);

然后就可以按照工具栏上的功能写逻辑了,比如删除笔记、修改注解等等。添加/删除笔记同时也伴随着高亮的标注,这个倒是比较简单:

// 添加
rendition.annotations.add('highlight', cfi, undefined, undefined, 'awaken-highlight');

// 删除
props.rendition.annotations.remove(note.cfi, 'highlight');

其中awaken-highlight是主题的一部分,后面会说。

功能到这差不多完备了,但还有点体验上的细节:

其一,一般来讲,对于一段已经标注好的笔记,我们往往希望点击它就可以弹出笔记工具栏,而不是需要选中。此时renditionmarkClicked事件就可以帮助我们。

其二,如果你看了epub.js关于文本选择的源码,可以发现其处理非常粗暴:以选择开始为起点,在150ms后判定结束返回事件,而这做法显然不合理,所以我做了点优化(当然仍然不想改工程,凑合改了):

const EVENT_NAME = bk.supportChangeFolder ? 'mouseup' : 'touchend';
(Contents as any).prototype.onSelectionChange = function(e: Event) {
  const t = this as any;

  if (t.doingSelection) {
    return;
  }

  const handler = function() {
    t.window.removeEventListener(EVENT_NAME, handler);
    const selection = t.window.getSelection();
    t.triggerSelectedEvent(selection);
    t.doingSelection = false;
  };

  t.window.addEventListener(EVENT_NAME, handler);
  t.doingSelection = true;
}

修改很简单,将选择结束的条件改为mouseup或者touchend就行了。

同步

书签和笔记的同步在内容上比书籍简单,因为只需要合并两个数组,但由于其有序并且存在同一条目的更新(修改注解),而且量可能较大还要考虑效率,所以更加麻烦。不过好在这也不是特别复杂的算法,凑合写了个大家看看就懂:

private _mergeNotes(localNotes: IBookNote[], remoteNotes: IBookNote[], removedTs: {[cfi: string]: number}): IBookNote[] {
  const res: IBookNote[] = [];
  let localIndex: number = 0;
  let remoteIndex: number = 0;
  let pre: IBookNote;
  let less: IBookNote;
  let preRemoved: IBookNote;

  while (localIndex < localNotes.length || remoteIndex < remoteNotes.length) {
    const local = localNotes[localIndex];
    const remote = remoteNotes[remoteIndex];

    const comp: number = !local ? 1 : !remote ? -1 : parser.compare(local.start, remote.start);
    if (comp === 0) {
      if (local.modified < remote.modified) {
        less = remote;
        remoteIndex += 1;  
      } else {
        less = local;
        localIndex += 1;  
      }
    } else if (comp === 1) {
      less = remote;
      remoteIndex += 1;
    } else {
      less = local;
      localIndex += 1;
    }

    // local
    if (less.removed) {
      removedTs[less.cfi] = Math.max(less.removed, removedTs[less.cfi] || 0);
      preRemoved = less;
      continue;
    }

    // remote
    if (preRemoved?.cfi === less.cfi) {
      if ((removedTs[less.cfi] || 0) > less.modified) {
        continue;
      }
    }

    // remote
    if (pre?.cfi === less.cfi) {
      pre.modified = Math.max(pre.modified, less.modified);
      continue;
    }

    if ((removedTs[less.cfi] || 0) > less.modified) {
      continue;
    }

    res.push(less);
    pre = less;
  }

  return res;
}

其中每个noteremoved属性仅存在于本地条目,而到了远端则变成removedTs中一部分。这么做首先是由于和书籍同步同样的原因,我必须要在远端存下来某个条目是否被删除了;其次不像书籍一样直接同步removed字段是由于一个笔记占用开销,同时按照上面这种优化实现逻辑上会出问题。

如何合并完远端和本地的笔记与书签后,本地存一份然后立即同步到远端,搞定。

Webview默认行为

Kindle笔记迁移

有了笔记功能,别忘了我最初是为了什么搞这个软件的——从Kindle迁移。所以终于到这一步了,就是如何将Kindle的书迁移过来,笔记也迁移过来。

Kindle的书籍迁移已经有很成熟的教程了,可以参考:一键批量下载 Kindle 全部电子书工具 + 移除 DRM 解密插件 + 格式转换教程 (开源免费),最后用calibre转换的时候选择EPUB即可。

接下来就是笔记迁移了,亚马逊提供了几种笔记导出方案,本来想都支持的,后来由于时间不够懒得搞了就只支持从桌面端软件(PC/Mac)导出的笔记,详细教程搜一下就有,最后导出的应当是名为XXXXX-笔记.html这样的文件。

这里吐槽一下亚马逊的同行们,你们连个html文件的输出都拼不对我真是服了...是因为html本身容错率太高导致看了下能渲染就没检查了是吧?

导出的笔记文件格式分析我就略过了,经过我的解析处理后,大致可以得到如下信息:

  1. 笔记的标注内容文本,去掉了空格填充。
  2. 如果有注解,注解文本。
  3. 笔记的“位置”。

看到这,读者可能觉得最直接的方法就是将笔记的“位置”转换为EPUBCFI就行,特别简单对吧?哪有这么好的事...在经过查询后,我大概了解了Kindle是怎么计算“位置”的:每128个字节算一个“位置”,这也就解释了为什么你复制中文文本甚至导出的笔记里都会插入这么多空格(我猜的),所以这路子行不通,那可咋整呢?

想来想去,最后还是只能用原始暴力的方法——文本搜索匹配。我直接把笔记拿出来全文搜索出那个片段总行了吧?正好epub.js也提供了对章节的find接口。但在满怀期待试了以后,发现...问题更复杂了:

  1. 脚注:很多书中都会有“xxx[1]yyy”这样的脚注,而由于EPUB本身是xml结构的,脚注会被单独渲染为一个结点,但搜索是基于结点的,直接去搜索根本搜不到。
  2. 段落:和脚注差不多的原因,搜索基于xml结点,跨段落直接跨结点了,搜不了。
  3. 重复:同一段文字会多次出现,对于笔记的“金句”而言不多见,但还是可能在译者评论中出现。

为了解决这些问题呢,我搞了个挺恶心的算法,具体太长就不贴了有兴趣自己去看吧,大概说下思路:

  1. query的前N个字获取rangeStart,后N个字获取rangeEnd,最后合并。
  2. N看实际情况来取,而textEnd需要在textStart(无脚注link)/linkEnd(有脚注link)的若干结点之内。
  3. 先判断是否有[\d+]的link,没有的话:
    1. 小于六个字的,直接全文搜索。
    2. 大于六个字的,拆成前六后六两部分,分别搜索后合并。
  4. 有的话
    1. 先查找到第一个link,然后反向搜索link前的文本的前(小于等于六个字),没有文本则直接以link为起点。
    2. 然后找到最后一个link,正向搜索link后的文本的最后(小于等于六个字),没有文本则以link为终点。

但即便如此,仍然无法覆盖所有情况,不过大部分情况已经可以覆盖了(只要你处理的是中文书籍),对于边界情况,我会收集起来在最后弹窗提示用户,并复制到用户的剪贴板。

主题切换

至此,主要的功能逻辑都搞定了,但由于个人的习惯,搞了这么久不加点私货是不可能的,所以主题切换功能就此诞生,其属于阅读设定的一部分:

export interface ITheme {
  name: string;
  color: string;
  background: string;
  highlight: string;
}

export interface IReadSettings extends ITheme {
  theme: number;
  fontSize: number;
  letterSpace: number;
  lineSpace: number;
}

由数据结构便可以看到阅读设定允许的内容:fontSize是文本大小,letterSpace是字间距,lineSpace是行间距,这几个单位都是rem。而theme就是最重要的主题了,name是主题名,color是文本颜色,background是背景色,highlight是标注高亮、注解、外部链接颜色。

如何修改这些字段的UI逻辑没必要赘述,这里比较重要的是如何让这些参数产生效果。还记得前面说过的后端的setBackground接口,以及初始化电子书时的stylesheet字段吗?setBackground配合阅读界面的背景颜色,来让异形屏客户端的非安全区统一颜色,这个没啥好说的。stylesheet则可以给渲染EPUBiframe添加一个css文件,而这个文件是我动态生成的:

export function buildStyleUrl(settings: IReadSettings): string {
  const style = `
body {
  color: ${settings.color};
  font-size: ${settings.fontSize}rem;
  line-height: ${settings.fontSize + settings.lineSpace}rem;
  letter-spacing: ${settings.letterSpace || 0}rem;
  touch-action: none;
  -webkit-touch-callout: none;
  word-break: break-all;
}

img {
  width: 100%;
}

a {
  color: ${settings.color};
  text-decoration: none;
  border-bottom: 2px solid ${settings.highlight};
}
  `

  return URL.createObjectURL(new Blob([style], {type: 'text/css'}));
}

当用户每次修改完样式确认后,我还需要去修改重新设置样式:

const applyReadSettings = async (rSettings: IReadSettings) => {
  const {background, highlight} = rSettings;
  await bk.worker.setBackground(
    parseInt(background.substring(1, 3), 16) / 255,
    parseInt(background.substring(3, 5), 16) / 255, 
    parseInt(background.substring(5, 7), 16) / 255
  );
  const sheet = document.getElementById('global-style') as HTMLStyleElement;
  sheet.textContent = `g.awaken-highlight {fill: ${highlight} !important;fill-opacity: 0.5;}`;
  setBookStyle(buildStyleUrl(rSettings));
  setReadSettings(rSettings);
}

其中global-style的修改是为了笔记高亮的样式(epub.js是用SVG实现的),而setBookStyle后执行的则是:

rendition.themes.register('awaken-style', props.bookStyle);
rendition.themes.select('awaken-style');

卸载之前的样式重新加载即可。

构建发布

开发完成后就是最终的构建发布了,这个在三端也有不同的做法。当然无论如何,第一步都是构建出生产环境的代码,我将最终代码构建到了dist目录,以备后续处理。

桌面端

桌面端的构建很简单,Tauri都帮我们想好了,我在tauri.conf.json中指定了资源目录build.distDir./assets,并用脚本将上一步构建后的产物复制到其内:

d=platforms/desktop/assets && rm -rf $d && mkdir $d && cp dist/* $d

最后执行切换到桌面工程目录下执行构建代码即可:

cd ./platforms/desktop && tauri build

最终的产物在./platforms/desktop/target/release/bundle内。

安卓端

移动端相较于桌面端,需要自己区分开发和生产环境,而且对资源的处理也要更复杂一些。

在安卓端,首先区分开发和发布环境是通过菜单的Build -> Select Build Variants窗口设置的,默认有debugrelease两种模式,在不同模式切换后会有个全局单例中的变量BuildConfig.DEBUG能判断处于什么环境:

if (BuildConfig.DEBUG) {
    ......
}

接下来在发布时,我们现将构建好的前端代码复制到指定目录:

d=platforms/android/app/src/main/assets && rm -rf $d && mkdir $d && cp dist/* $d

至于如何返回这些包内静态资源呢?也很简单,在前面我已经在客户端实现了Webview中XHR的拦截,现在只要在里面添加一些逻辑即可。

首先,在发布环境下,我需要将Webview加载的url从调试的dev-server的地址,换到我们拦截的schema

mainWebView?.loadUrl(if (BuildConfig.DEBUG) { host } else { "http://awaken.api" })

然后对于所有在之前接口协议之外的method,全部认为是对包内静态资源的请求,然后进行拦截处理:

fun loadAsset(url: String): InputStream {
    return mContext.assets.open(if (url == "") { "index.html" } else { url })
}

可见在安卓上对包内资源的请求是很简单的。在完成这些后,我们还需要给应用提供一个签名,用Build -> Generate Signed Build or APK即可。

最终构建产物在platforms/android/app/release中。

iOS端

iOS的工程配置是完全由XCode管理的,单纯从构建区分来讲并不复杂。

首先是区分开发和发布环境,这个只需要在工程配置文件的Build Settings -> swift compiler - Custom Flags -> Other Swift Flags中,给Debug配置加上-DDEBUG,给Release配置加上-DRELEASE,然后创建两个构建的Schema,在其中分别使用哪个环境,最后就可以在代码中使用这些编译选项了:

#if RELEASE
......
#else
......
#endif

接下来在发布时,我们现将构建好的前端代码复制到指定目录:

d=platforms/ios/Awaken/assets && rm -rf $d && mkdir $d && cp dist/* $d

随后在XCode项目配置的Build Phase -> Copy Bundle Resource中,将assets目录添加进去,让其能打入构建包。随后便可以在代码中的发布分支中编写具体逻辑了:

let rp = Bundle.main.path(forResource: "index", ofType: "html", inDirectory: "assets")!
do {
    let str = try String(contentsOfFile: rp, encoding: .utf8)
    wkWebView.loadHTMLString(str, baseURL: URL(string: "awaken://awaken.api")!)
} catch {
    wkWebView.load(URLRequest(url: URL(string: host)!))
}

可见这里比安卓要复杂不少,我先读取了本地Bundle内的index.html,然后指定了我们实现了XHR拦截的地址awaken://awaken.api作为baseURL。接下来WKWebView就会以这个地址为基础去请求JS、CSS等静态资源了。

之所以要提前读取html的内容,是因为通过XHR拦截返回index.html会出现问题。

静态资源的处理和安卓基本一致,对于不满足既定接口方法的请求直接尝试加载本地资源即可:

private func loadAsset(url: String) throws -> FileHandle {
    let fp = url == "" ? "index.html" : url
    let nameExt = fp.components(separatedBy: ".")
    let rp = Bundle.main.path(forResource: nameExt[0], ofType: nameExt[1], inDirectory: "assets")
    let file = FileHandle(forReadingAtPath: rp!)

    if (file == nil) {
        throw "File load error: \(url)"
    }

    return file!
}

结语

搞之前没想到还挺麻烦的,要是之前有人搞了我也不会花着精力,毕竟这甚至不属于我既定项目的一部分。不过总体来讲,做完还挺有成就感的,未来有别的项目也可以作为模板打个样。

无论如何,我过去从开源社区已经索取了这么多,那么尽可能做出力所能及的回报也是必要的。无论做点啥总都比躺着伸手祈求强,毕竟——

意义并不在于‘包罗万象’的观测,而存在于任一行动的回响之中。

接下来就是去读书然后完成既定的主线项目了。

如果不是自己的创作,少女是会标识出来的,所以要告诉别人是少女写的哦。