老刘
发布于 2026-10-15 / 2 阅读

技术选型的取舍——三端技术栈为什么这样选

技术选型的取舍——三端技术栈为什么这样选

旦阅诞生记 · 第 03 篇

上一篇把旦阅的整体架构摊开讲了一遍:三端一套算法,靠一份跨端契约(BookKey + 进度参数)把手机、电脑、服务端绑成一个整体。这一篇往回退一步,讲讲这三端的技术栈是怎么选出来的——以及每一个"选了它"的背后,都对应着一个"放弃了什么"。

选型这件事,最容易写成"候选技术对比 + 星级评分"。但真实的选型很少是打分打出来的,更多是被几条硬约束挤出来的。旦阅三端加起来,真正的约束只有四条:

  1. 手机端要原生级的排版手感——分页必须和屏幕渲染完全同源;
  2. 桌面端要和服务端共享同一套算法代码——同一个算法不允许存在两份实现;
  3. 服务端要一个人也能运维——单机、零外部依赖、容器随便重建;
  4. 正文永远不出设备——服务端只存进度,不存书。

下面逐端拆开。

一、先看全景

端 技术栈 一句话理由
安卓端 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——同一本书,两台设备怎么认出彼此。