技术选型的取舍——三端技术栈为什么这样选
旦阅诞生记 · 第 03 篇
上一篇把旦阅的整体架构摊开讲了一遍:三端一套算法,靠一份跨端契约(BookKey + 进度参数)把手机、电脑、服务端绑成一个整体。这一篇往回退一步,讲讲这三端的技术栈是怎么选出来的——以及每一个"选了它"的背后,都对应着一个"放弃了什么"。
选型这件事,最容易写成"候选技术对比 + 星级评分"。但真实的选型很少是打分打出来的,更多是被几条硬约束挤出来的。旦阅三端加起来,真正的约束只有四条:
- 手机端要原生级的排版手感——分页必须和屏幕渲染完全同源;
- 桌面端要和服务端共享同一套算法代码——同一个算法不允许存在两份实现;
- 服务端要一个人也能运维——单机、零外部依赖、容器随便重建;
- 正文永远不出设备——服务端只存进度,不存书。
下面逐端拆开。
一、先看全景
| 端 | 技术栈 | 一句话理由 |
|---|---|---|
| 安卓端 | uni-app x 蒸汽模式 + UTS 原生插件 | 要原生渲染,又要给排版留一个原生逃生口 |
| 桌面端 | WPF(.NET 10) | 与服务端同属 .NET,核心算法三处共用一份 |
| 服务端 | ASP.NET Core Minimal API + EF Core + SQLite | 单文件、零外部依赖、一个人运维得过来 |
三个栈没有一个是"最时髦"的,但拼起来恰好满足那四条约束。
二、安卓端:uni-app x 蒸汽模式
安卓端是三端里约束最硬的一端。阅读器的核心体验是分页:屏幕上每一行放哪些字符、每一页放多少行,必须和实际渲染完全一致,差半个像素就会页尾留白或者最后一行被吃掉。
这意味着分页算法必须拿到渲染引擎自己的折行结果,不能用自己估算的字宽去猜。Android 平台上有现成的权威答案——StaticLayout,TextView 内部折行用的就是它。把一章文字喂给它,就能拿到"每一行放哪些字符"的官方结果。
问题在于:这个类只存在于 Android 原生层,Kotlin/Java 才能直接调。所以安卓端的选型,本质上是在选"怎么够到原生层":
- 纯原生 Kotlin:能直接用
StaticLayout,但书架、阅读页、设置面板全部手写,界面迭代效率低; - Flutter / React Native:界面效率高,但原生能力都要靠 MethodChannel / Bridge 做逃生口——通道两头是两套语言、两套类型系统,分页这种高频、大数据量的调用会被序列化成本卡住;
- uni-app x + UTS 插件:uvue 写界面,UTS 插件直接编译成 Kotlin 跑在原生层,uvue 与 UTS 之间没有 JS 桥、没有序列化,调原生方法就是普通函数调用。
第三条路最贴合"既要界面效率、又要原生逃生口"的需求,于是安卓端定了它。manifest.json 里就是一行开关:
"uni-app-x": { "vapor": true }
蒸汽模式(Vapor)是 uni-app x 的新一代渲染引擎:去掉虚拟 DOM,模板与样式直接编译为字节码,走原生渲染管线。但真正让这个方案成立的是分层设计——页面永远不直接碰原生 API,所有原生调用收敛到一个 UTS 插件:
pages/*.uvue 界面与交互(组合式 API)
│ 只 import 门面,不依赖插件
▼
api/native.uts 原生能力门面(类型收口)
▼
uni_modules/x-reader-native UTS 插件(编译为 Kotlin)
▼
StaticLayout / 文件流 / TTS Android 原生 API
这个"逃生口"后来承担了整个阅读器最重的几块活:StaticLayout 分页、大文件流式读取、编码探测、系统 TTS。
代价
蒸汽模式的约束相当多,摘几条写进了项目 README 的对照表:
| 蒸汽模式约束 | 本项目的做法 |
|---|---|
| 仅支持组合式 API | 全部页面/组件使用 <script setup> |
| 只支持简单 class 选择器 | 样式全部扁平 BEM 类名,无嵌套选择器 |
| uvue 页面内不能直接调原生 API | 原生调用全部收敛在 UTS 插件 |
number 是装箱 Number |
喂给原生 API 必须显式转换 |
最后一条最阴险。UTS 的 number 编译为装箱的 Number,而 Android API 要的是原始类型,必须显式转换:
paint.setTextSize(fontSize.toFloat()); // 要 Float
layout.getLineTop(line.toInt()); // 要 Int
win.addFlags(flag.toInt()); // 要 Int
这类错误在 UTS → Kotlin 的源码转换阶段完全发现不了,只有 kotlinc 真正编译时才报错——所以在这个栈里,"编译通过"不等于"能跑"。这是选它之前没想到、之后天天在付的成本。
另一个代价是热更新:wgt 热更新要求 HBuilderX 5.31+,本地是 5.26,用不上。于是现阶段每次发版都重打整包——对一个自用应用可以忍,但它提醒我:选型时盯着的往往是开发期成本,发布期的成本要在第一个版本之后才浮出来。
三、桌面端:WPF
桌面端的约束不是体验,是代码复用。
旦阅的跨端算法——BookKey、章节切分、编码探测、分页——在 .NET 侧已经有一份参考实现(服务端的 BookKeyUtil.cs)。桌面端如果选别的栈,这套算法就得再抄一份,然后永远活在"两份实现是否一致"的焦虑里。上一篇讲过,我们的解决办法是单元测试 + 参照实现交叉校验;而这个办法成立的前提是:桌面端与服务端同属 .NET。
所以选 WPF 的决定性理由只有一句话:EggBook.Core 可以被桌面端、自检工具、单元测试三处直接引用同一份代码。工程文件本身就写明了这个意图:
<!-- EggBook.Core.csproj:跨端算法层 -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<!-- 刻意不依赖任何 WPF 类型,方便被桌面端、校验工具与单元测试共用 -->
</PropertyGroup>
<!-- 没有 ItemGroup:零第三方依赖。GBK/GB18030 解码由
net10.0-windows 运行时自带的 CodePagesEncodingProvider 提供 -->
</Project>
两个细节值得注意:
- Core 不引用任何 WPF 类型。界面层和算法层被工程引用关系硬性隔开,算法层想"顺手"用界面类型都做不到;
- 零第三方包。连 GB18030 解码都直接用运行时自带的
CodePagesEncodingProvider,没有PackageReference。依赖越少,三端口径同步时越不容易被"某个包的版本差异"背刺。
WPF 本身对这类"列表 + 抽屉 + 设置面板"的密集表单型界面是合手的:数据模板驱动的 MVVM,界面即数据绑定。它也给了你足够的绳子把自己绊倒——v1.0.1 的目录抽屉显示一排类名,就是因为三个列表里唯独它没写 ItemTemplate,WPF 默认回落到 ToString():
// v1.0.1:少了 ItemTemplate,ListBox 把 ViewModel 的类型名画了出来
CatalogList.ItemsSource = Chapters;
// 每一行显示:EggBook.Desktop.ViewModels.ChapterItem
发布形态是 .NET 的另一份红利——自包含发布让目标机器免装运行时,两种形态按需取用:
# 绿色版文件夹:257 个文件、约 133 MB,启动最快
dotnet publish src/EggBook.Desktop -c Release -r win-x64 \
--self-contained true -o dist/win-x64
# 单文件 exe:约 59 MB,拷来拷去方便,首次启动稍慢
dotnet publish src/EggBook.Desktop -c Release -r win-x64 \
--self-contained true -p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true -o dist/win-x64-single
代价
- 放弃了 macOS / Linux 桌面。旦阅的桌面端只做 Windows,这是明确的自我设限——反正我自己只用 Windows;
- 没买代码签名证书,首次运行会被 SmartScreen 拦一道。自用软件可以忍;哪天要分发给陌生人,这就是第一笔要补的成本。
四、服务端:Minimal API + SQLite
服务端的约束是一个人运维。它跑在一台小服务器上,只做一件事:记录"读到哪儿"。为了十来个接口去养一套 MySQL + Redis,是用大炮打蚊子。
先看骨架。Program.cs 全文不到 120 行,配置、数据库、认证、路由一屏读完:
var builder = WebApplication.CreateBuilder(args);
// SQLite:单文件、零外部依赖
var connectionString = ResolveSqliteConnection(
builder.Configuration.GetConnectionString("Default")
?? "Data Source=App_Data/eggbook.db");
builder.Services.AddDbContext<AppDbContext>(opt => opt.UseSqlite(connectionString));
// JWT Bearer 认证
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => { /* TokenValidationParameters ... */ });
var app = builder.Build();
// 首次启动自动建库;免登录模式准备内置用户
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
if (!requireLogin) AnonymousUser.Ensure(db);
}
app.MapAuthEndpoints();
app.MapProgressEndpoints(requireLogin); // 免登录单用户:填地址即用
app.MapFingerprintEndpoint(); // BookKey 一致性自检接口
几个取舍逐条说。
Minimal API,而不是 Controller。 放弃 Controller 的分层仪式感,换来协议层没有任何魔法:每个端点就是一个普通函数,参数绑定、依赖注入、返回值全部显式。分文件靠扩展方法(MapAuthEndpoints() / MapProgressEndpoints()),而不是分层目录。十来个接口的服务,这个粒度刚刚好;如果接口数量再上一个数量级,这套组织方式就该换回 Controller——这是明确知道、且愿意届时再付的成本。
SQLite,而不是 MySQL。 整个服务的状态就是一个 .db 文件,Docker 起一个命名卷挂上去,容器重建数据无损。EF Core 在这里主要贡献迁移与类型安全,没有用它任何"高级"特性。
JWT 密钥固定,而不是随机生成。
// 密钥解析:优先用配置(Jwt__Key),没配就回落到三端约定的内置固定密钥。
// 自用体系三端统一这一个值,所以不会随重启/容器重建变化,已签发的令牌长期有效。
jwt.Key = JwtKeyResolver.Resolve(jwt.Key);
这是明确"不标准"的决定:正规做法是密钥只放配置、定期轮换。但旦阅是自用系统,三端约定同一个值换来的是——容器随便重建、令牌长期有效、手机端永远不用重新登录。对个人服务,这笔交换是划算的;哪天开放多用户,第一件事就是改它。
免登录单用户模式,"地址即配置"。 Auth:RequireLogin 默认 false:未带令牌的请求一律归到内置用户 local。这个决定反过来简化了桌面端——登录、注册、退出的全部 UI 与逻辑被删掉,桌面端唯一的同步配置就是服务端地址,填上就能用。
最后看 Dockerfile 里的两个细节,最能体现"零外部依赖"执行得有多彻底:
# 这里刻意不写 `# syntax=docker/dockerfile:1`:那行会让 BuildKit 去 Docker Hub
# 拉 frontend 镜像,而国内网络常无法访问 Docker Hub(DNS 污染)。
# 健康检查用 bash 自带的 /dev/tcp 代替 curl,
# 不在镜像里装任何额外软件包(装包要访问 apt 源,又是一个外部依赖)
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \
CMD ["bash","-c","exec 3<>/dev/tcp/127.0.0.1/8080; printf 'GET /health HTTP/1.0\\r\\n\\r\\n' >&3; grep -q '200' <&3"]
连 syntax 指令和 curl 都被当成了外部依赖砍掉。基础镜像全部走微软 MCR,构建过程不碰 Docker Hub。实测:镜像 402 MB、启动约 10 秒转 healthy、28/28 冒烟通过。这套东西单看每一条都挺"土",合起来的效果是:构建永远成功,部署永远成功——对一个人运维的服务,这比优雅重要得多。
五、契约比栈重要
技术栈可以三端三样,但有一件事必须三端一模一样:对"同一本书"和"读到哪儿"的定义。这比选型更重要——栈选错了可以局部重写,契约不一致则整个同步体系失效。
书籍身份走归一化后的 BookKey:
去 BOM → 行尾统一为 LF → 去首尾空白 → UTF-8 MD5 → md5:<32位hex>
阅读位置按优先级兜底,权威是全书绝对字符下标:
第 1 优先 charOffset 全书绝对字符下标 —— BookKey 一致时最精确
第 2 优先 progress 全书进度比例 0~1 —— 正文有细微差异/重排时的兜底
第 3 优先 chapterTitle 章名对齐 —— 两端章节表不一致时
第 4 优先 chapterIndex 最后手段,仅展示
而一条贯穿始终的边界是:共享身份与位置,不共享呈现。字号、行距、主题、翻页方式全是设备本地参数,不参与同步——同步它们只会打架,因为它们依赖各自的屏幕。
六、回头看:代价清单
选型不是一次性动作,每一条都是一张欠条。欠条到期时值不值,回头看才知道:
| 决策 | 付出的代价 | 回头看 |
|---|---|---|
| uni-app x 蒸汽模式 | 一堆约束要背;wgt 热更新用不上,每次整包 | 值。StaticLayout 分页是体验的根,逃生口一直在 |
| UTS 插件收敛原生调用 | 多一层门面、多一层间接 | 值。原生类型错误全部堵在插件层,页面层很干净 |
| WPF,仅 Windows | 放弃 macOS / Linux 桌面 | 值。换来 Core 三处共用一份实现 |
| 纯 BCL 零第三方包 | 少数轮子要自己造 | 值。依赖越少,跨端口径越稳 |
| SQLite 单文件 | 并发写能力有限 | 值。单用户进度同步,绰绰有余 |
| JWT 固定密钥 | 不符合密钥管理最佳实践 | 自用可接受;开放多用户前必须改 |
| 免登录单用户 | 默认放弃多用户隔离 | 值。桌面端连登录 UI 都省了 |
| Docker 砍掉 curl 与 syntax 指令 | 健康检查写法很土 | 值。国内网络构建永远成功 |
如果把这八条压缩成一句话,就是我在给旦阅选型时反复用的判断标准:
选型不是选"最强的",是选"错了最便宜的"。
判断一个选型好不好,别看它演示时多漂亮,看它出问题时你能不能在一小时内定位到、能不能只改一处就把错修掉。旦阅这一路修下来的十几个 bug,每一个都修在"出错的那个端",没有一个要求三端同时动刀——这大概是这套选型最值钱的特性。
下一篇进入第二辑,讲旦阅最核心、也最容易翻车的一个算法:BookKey——同一本书,两台设备怎么认出彼此。