塞壬唱片桌面版技术分析 - Shiina's Blog

塞壬唱片桌面版技术分析

2024 年 8 月 24 日 - 18:26:37 发布
20.6K 字 69分钟
本作品采用 CC BY-NC-SA 4.0 进行许可。

塞壬唱片桌面版技术分析

开篇的碎碎念

这个玩具从去年7月中旬立项到今年8月底终于基本完成,前前后后经历了十分甚至九分多的困难,但最终还是把一个可以用用的成品做出来了,虽然bug还是很多(逃。项目的目的是为了让所有开发者看到这样一种可能性,就是 “在网站内嵌入一个完整应用,同时与原网站结合,并实现功能增强” 是存在可能性的;其次也算是对tauri进行了一个深度体验(误,事实证明tauri本身还是可以的,就是有一些功能实现可能没有像electron那样成熟。

在读懂本文前你需要有以下基础:

  • 熟练使用 js / ts / react
  • 对 vite 脚手架有一定的了解和使用
  • 有 rust 入门基础
  • 知道 monorepo 是什么

再说回 tauri 本身,tauri 面临的最大的问题是:

  • rust 入门门槛较高,熟练使用有一定成本,业务上线速度没 electron 快
  • 每个客户端的 webview 环境可能都不一样,需要做多端环境测试,而 electron 优势就是自封 chrome,保证环境一致,开发体验会很好
  • 各种功能可能都没有 electron 成熟,用户自身也并不是特别在意软件大小,甚至可能会因为体积够大觉得软件够健壮而感到安心

因此 tauri 目前可能更适合一些轻量级工具的开发,以及内部使用的软件,对外使用我还是会选择 electron。tauri 与 electron 对比一直是个争论不休的问题,各自都有有点,我们能做的就是物尽其用,找到每个工具适合的方向,而不是单纯为了发泄情绪而吵架,翻过几个tauri视频,评论区都是乌烟瘴气的,比起讨论程序本身更像是抱怨自己遇到的各种不满。

go 语言也有个类似的 wails 框架,使用的也是 webview,其优势在于 go 比 rust 入门成本要简单,做业务用这个会更快点。

准备工作

前言

其实做这个玩具的主要是为了体验一下 tauri 开发,去年那个时候刚好开始rust入门,就想着拿这个练练手,没想到一做就是做这么久;还有就是体验工具链的配套开发,网站打包载体用的还是 vite,写了一些插件,使得整个app可以在原网站的基础上进行功能制作。

tauri 作为 electron 的竞品,自然是有他的优点,然し tauri 相比 electron 也有一些稍微不成熟的地方,且由于使用 rust 的关系相比 electron 的后端开发没那么自由。

了解网站的技术栈

这个很好处理,直接打开页面源代码,可以看到有 umi 的打包前缀,那就很好解释了。即基本开发使用了 react + dva + umi 脚手架,路由也是 umi 负责接管。知道了技术栈就知道可能会暴露哪些 api,开发相对就方便很多。

vite 层面的准备

这里主要讲讲如何实现的 vite 在启动脚手架时就能够直接对接塞壬唱片官网并进行二次开发。

选取 vite 的原因是作者对这个脚手架相对来说使用熟练,知道该怎么进行围绕开发和工程定制。

我们实现插件的目标是:保证在开发时 html 渲染的是唱片官网页面,同时还包含了 vite 原来的 html 文件字符串,且官网原来所有的静态资源文件都能被正常加载。

src/vite_plugin/website-inject/index.ts 进行了插件实现。

vite 刚好有一个 hook 叫 transformIndexHtml,他可以在每次 html 渲染前执行插件的代码,并重新输出结果,因此在这里我们就直接进行远程 fetch 获取原网站的 html 内容,并与本地 html 内容进行拼接。

几个要注意的点就是:原网站他是 ssr 渲染的,在某些情况下会直接发送静态文件,具体表现为 ssr 渲染的时候会有个 script 来初始化网站的 store。这个时候需要去除掉,原因后面会解释。除此之外还去除了 manifest(PWA相关文件),还有将 cdn 指向的静态资源重定向到本地后端。当然 cdn 不改也可以,问题就是没法进行核心 store 的暴露,那这样就很难办了。如果没有实现 cdn 代理那这个应用单纯就是套壳而已,你可以在上面加脚本,缺点就是没法跟内置应用结合的那么好。

rust 层面的准备

这里一开始没有准备啥,最多就是修改了 tauri.conf.json

要看的就是 windows 平台的配置,我们整个应用目前只有一个窗口,且为了沉浸感体验,我们关闭了 windows 原生的顶部栏和边框;软件分辨率则是跟着网站最小分辨率来设置的。

主应用 - 前端

页面入口文件

在经过插件处理后我们页面返回的 html 如下:

<!DOCTYPE html>
<html>
<head>
  <title data-react-helmet="true">塞壬唱片 - A WORLD FAMILIARLY UNKNOWN</title>
  <meta charset="utf-8">
  <meta name="viewport"
    content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no">
  <link rel="shortcut icon" type="image/x-icon" href="http://localhost:11451/siren/site/favicon.ico">
  <!-- 静态资源 -->
  <script>
    window.routerBase = "/";
  </script>
  <script>
    //! umi version: 3.2.16
  </script>
  <meta data-react-helmet="true" name="theme-color" content="#333333">
</head>
<body>
  <div id="root">
    <!-- ssr 渲染结果 -->
  </div>
  <script src="http://localhost:11451/siren/site/umi.87fedd26.js"></script>

</body>
</html>
<!-- vite 注入代码 -->
 <!-- react dev hmr -->
<script type="module">
  import RefreshRuntime from "/@react-refresh"
  RefreshRuntime.injectIntoGlobalHook(window)
  window.$RefreshReg$ = () => { }
  window.$RefreshSig$ = () => (type) => type
  window.__vite_plugin_react_preamble_installed__ = true
</script>
<!-- vite hmr -->
<script type="module" src="/@vite/client"></script>
<!-- 我们自己的代码 -->
<script>
  // 下文的 app 和 sidebar 会被注入到页面中
  window.siren_store.getState().player.volume = 0;
  const injectApp = document.createElement("div");
  injectApp.id = "inject-app";
  injectApp.classList.add("inject_app_class_namespace")
  document.body.appendChild(injectApp);

  const injectSidebar = document.createElement("div");
  injectSidebar.id = "inject-sidebar";
  injectSidebar.classList.add("inject_sidebar_class_namespace")

  document.body.appendChild(injectSidebar);

  // 未联网处理
  // 需要屏蔽 api 请求、在线音乐获取
  if (!navigator.onLine) {
  }
</script>
<!-- 入口 ts 文件 -->
<script type="module" src="/src/main.tsx"></script>

虽然 html 不是单独 root 了,但暂时不影响页面正常使用。入口文件要做的事情有些多,先从起始的 react app 渲染开始。

笔者在这里分了两个 react root app:


ReactDOM.createRoot(
  document.getElementById('inject-app') as HTMLElement,
).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);

ReactDOM.createRoot(
  document.getElementById('inject-sidebar') as HTMLElement,
).render(
  <React.StrictMode>
    <SidebarWrapper />
  </React.StrictMode>,
);

第一个是 app 本身,即要嵌入到页面的部分;另一个则是侧边栏,把他们分离的原因是 app 本体可能由于不是很稳定,容易整个崩掉,这导致连带着侧边栏都没法操作,而侧边栏本身只进行简单操作,对页面以及其他的副作用不是那么多。因此分离后至少不会说让软件崩的那么难看。

其余的就是页面副作用施加,比如添加全局监听事件,全局按键什么的,这一些都应该归类到他自己的文件夹下面去。

全局状态与塞壬唱片原生 store

笔者在这里选用的是 nanostores 作为一个状态管理器,优点是轻量级,模块化;缺点就是大部分操作 api 都需要自己定义,且如果管理不当性能可能会很差。

原页面 store 的导出则是 rust 层面辅助完成的,在这之前笔者调研过各种暴露 store 的方法,但由于时间与技术力限制没法做到前端直接通过脚本导出(存在纯前端实现导出的可能,毕竟 react dev 工具可以看到 store 变量,并且可以暴露到全局)。目前的处理方式是后端直接通过修改响应的 js 源码直接在 window 上暴露。

页面中使用 nanostores 状态与原生 store 并不怎么耦合,两者几乎没有关联的地方。

在页面侧边栏是使用 nanostores 一个比较集中的地方,即应用配置,为此笔者写了一个 EffectManager 用来集中管理 store 造成的副作用(保存配置文件到本地等)

说实话这个 manager 写的不是特别好,类型系统做的也比较差,具体的用例在 src/store/models/settings/index.ts。他导出了一个保存配置到硬盘的方法和 Manager 本身。

还有一个可以说的就是原生 store 的类型系统,虽然导出来的类型是 any,但是我们知道页面用的是 dva 啊,那不就是 redux 套壳,这代表我们可以自己些类型系统,给开发过程一个更好的体验,不至于说在调用原生 store 时还要看看控制台输出的 store 调用内容。

原生 store 的导出类型位于 src/types/SirenStore/index.ts。可以看到 SirenStore 的包装类型是 Store<SirenStoreState, Actions> & SirenStoreCollect,主要看前面的 Store 与泛型:

  • 第一个泛型参数代表 store 的状态类型
  • 第二个则是包含的 actions 类型

actions 则是自己手动提供类型,可以在 src/types/SirenStore/action.ts 中找到,他是一个联合类型,包含了在开发过程中遇到的大部分 action。

接下来如果你想要调用 dispatch 等操作会发现他有了完美的智能补全:

原生 store 类型补全

state 本身不能直接拿来用,因为使用 typeof 导出的类型不一定正确,比如空数组会直接判定为 never[],因此还要手动声明一下类型。

组件库

组件库没什么好说的,设计主要是为了与原网站融合,保持风格一致。

不过我有个UI朋友在看完后直接不留情面的来了一句怎么这么难看啊。哎,难绷。

不过好在功能还是可以正常用的,放在 app 里面看着也不会说不协调,可能就是布局稍微有点问题。

这里不选择基于某个组件库进行二次封装的原因就是样式不匹配,而且很多行为可能需要定制。

组件库里定制最久的是滚动条,这个组件很有意思,用的是 ReactCustomScrollbar + 外层 div clipPath 来处理这种效果。这个组件在我的 AOI player 制作过,我就直接拿过来用了。

然后是右下角的全局消息通知,说实话做的不太满意,因为是基于 mui 封装的,mui 又做的非常基础,很多东西可能都需要自己手动实现。 现在是实现了单一消息展示,以后可能会使用新的组件库来处理。这部分代码写的很烂,不知道以后会不会重构。

路由与页面原生嵌入

这部分算是处理起来相对最麻烦的一个部分,因为需要完美与原页面契合,其次是要与原生页面切换动画搭配。在这里我是自己新建了一个 inject-router-view 用来专门放自定义页面,这个元素嵌入到了 layout 下面,因为这下面的元素相对固定,同样的,背景元素的嵌入也是放在这下面。一定要嵌入的原因是保持与整个 app 的 z-index 相同,这样不会出现覆盖导致无法操作页面的问题。

然后是缓存,在原网站每个页面都是按需渲染,且在离开时会缓存在页面上,这样二次进入的时候就会快很多,笔者这里也是这样实现的,代码位于src/router/core/CustomView.tsx。还有就是路由进入与离开动画,这里我是让组件自行实现,外部会传入一个 active 变量用来告诉你是否该展示了,然后你可以依赖纯 css 或 js 来实现路由切换动画,元素是滞留在页面上,不会被移除,所以不用担心动画不完整的问题。这里其实还可以进一步优化,源码中调用的 Component 函数相当于再创建一次 fiber 节点,只是 props 发生变化而已,是否有机会把几个不同状态的 fiber 创建出来,到时候直接切换创建好的节点,而不是重新生成呢(逃。

接下来是右上角的菜单,这里首先是把登录进行了移除,因为这真没必要吧,也不知道以后官网会出什么需要登陆的功能。然后是一波逆天副作用的 DOM 操作,源码位于 src/router/core/index.ts。样式基本都是抄原网站的,由于关掉了严格模式,所以在开发环境下也只执行一次。

页面是否要加入到导航栏快捷跳转则是根据路由表来处理,路由表有个 addToNav 选项用来控制。

接下来要做的是与原生 store 的 router 进行绑定,源码位于 src/store/models/router/index.ts。笔者创建了一个 store 用来管理自定义路由,并监听原生 store 的变化,捕获原生页面路由的位置并同步和进行一些自定义操作,具体看源码吧,解释了为什么需要做这些工作;还有一个是 router 自身也通过 rust 的修改进行了暴露,这样可以调用上面的 push 等方法来进行底层操作。

原生页面的渲染在 store 分为 initPageactivatePagepageEntered 等,猜测大概率是跟页面动画播放有关,当你在首次进入与二次进入一些页面时可以发现两次进入的动画是不一样的,播放页面最为明显。

然后是未实现的部分:滚轮切换页面,原生页面是支持鼠标滚轮切换的,笔者由于技术力限制没法完美实现,索性这个功能就先不开了。

题外话:当你处在非音乐页面时如果发生了切歌,网址 url 是会发生变化的,且 store 内的路由也会发生变化,虽然原网站在内部处理了这种情况,但是在外面嗯造 app 的我麻烦就多了挺多的,比如页面的背景模糊也会变化,这个在接下来会说。

背景图片与路由切换动效

背景算是全局一个相对重要的功能,从 UI 角度来说算是整个网站氛围支撑的关键,原网站的背景虽然偏黑,但是仔细观察还是有很多细节,比如首页其实有一个大logo,得纵观整个页面才看得出来,不过今天在这里讨论背景如何实现,而不是纠结 UI 审美。

css 移除网站背景代码:src/styles/rewrite.scss

首先是移除原背景,原网站每个路由页都有一个不同的背景,但是所有的路由页都会存放到 #layout 元素下的第一个 div。

因此我们可以写出第一个部分的 css

#layout.background__instead {
  /** 基础清空,保证文字可以看清 */
  background-image: none;
  background-color: #000;

  /* 放置路由元素的容器 */
  & > div:first-child {
    /* 路由元素自身 */
    & > div {

    }
  }
}

接下来就是看看每个页面,他们自带的背景图片位于哪个元素,可以使用 css 选择器选出来,背景元素基本都有 background 前缀,直接选就是了,然后稍微特殊点的是专辑选择,单独给他安排一下就好:

#layout.background__instead {
  background-image: none;
  background-color: #000;

  & > div:first-child {
    & > div {
      background-color: transparent;
      background-image: none;

      // 每个路由页的背景
      & > div[class^='background'],
      & > div[class*='albumSelect'] {
        background-color: transparent;
        background-image: none;
      }
    }
  }
}

使用纯 css 相比直接移除元素的好处就是可以恢复,且没有 js 副作用,还稳定。

接下来就是自定义背景嵌入,这个就简单很多了,直接嵌一个自定义元素到前文提到的自定义 router-view 元素内,路由切换时监听一波然后猛猛改 css 就完事了,关闭自定义背景直接移除掉元素。源码一些细节就不扣了,里面都有注释。

最后说一下 bug,就是原生页面切歌时路由也会变化,这导致背景样式会跳到音乐页面去,目前这个问题暂时没法解决,以后看看吧。

全局 API 代理内容

全站 api 我之前有做过一个 Nodejs API 的项目,但是在实际开发中基本上是很少直接请求 api,一般都是尽量通过 store 操作来改变页面状态,因为原生 store 操作相对稳定很多,有质量保证,只有很少的地方才会需要直接请求 api。

这里大部分内容都是在 rust 层面,前端改变的东西不多。

部分碎碎念内容

页面大部分地方的宽高都是限定的,其最大程度避免了浏览器触发重绘,当然有一些地方做的还是不够好。

下载页面其实做好了,但是后端实现有些麻烦,需要设计新架构什么的,你可能还要考虑一下断点续传,异步编程。以后有空了想练习 rust 的时候会考虑启用下载页面。

播放列表页的右侧列表是虚拟列表,原本在设计的时候考虑使用斑马纹,但是由于虚拟列表会发生元素动态插入导致样式闪烁,所以就没有用了。

音乐播放页的图片切换,系统托盘信息以及改变播放专辑都是依赖 src/init 的副作用操作,神奇吧。系统托盘改变信息在前端实现是比后端更方便的,因为前端可以直接获取 store 内信息。

音乐播放页的下载按钮来源于 src/optimize。我也不知道为什么要放那里,但就是放了。

还有个对页面的尝试优化是优化音乐页面的图片,那里所有的图片在原网站中会直接获取,打开控制台就会发现有 114514 个请求,体验很差。目前的优化手段是加上了浏览器原生 lazy 属性,不过实践下来效果一般,当只要元素在页面上展示出来了,那么就还是会触发图片下载,也就是说只要你进入页面后直接跳到最后去,那么此时懒加载就退化成无效果了,唯一可能的继续优化方式是监听页面变化,在跳到当前页时再用 js 修改 img 的 src,以此来实现真正的懒加载。

主应用 - 后端

在开始之前

前我们首先要确定前端需要什么,其次是如何处理响应内容,我们如何修改才能达到目的。

首先是网站静态文件,我们必须修改响应体的 js 才可以暴露出控制网站核心的变量;其次是 API,各种歌曲信息和专辑列表都是依赖于 API 来获取。

大头就是 CDN 和 API,其余一些细节会在需要的时候讲一下。

请求库选用的是 reqwestwarp,没什么理由,单纯是因为这库用的人多。异步运行时则是 tokio,笔者不是特别懂异步调度这些,使用 tokio 也单纯是解决问题而已,底层还没怎么学。

其次是一些文件读写,以及通信问题。

CDN 核心代理

rt,cdn 用来控制网站内容缓存、响应资源重写与拦截响应内容;api 则用来拦截后端返回内容。这两个服务是核心中的核心,整个应用修改都是依赖这两样服务实现的。

cdn 实现位于 src-tauri/src/proxy/cdn_proxy.rs

能说的内容就是缓存,首次请求的内容会放入 kv map 缓存,且他们全部放在内存中,不会写到硬盘上,这样保证短期内缓存不会与服务器内容不一致。

大部分代码都是流水线代码,论性能优化我还差的好远(恼

还有一个就是静态资源内联实现离线访问,用了 include_dir 直接内联,网站爬取是在 vite 插件内实现的,具体看源码吧。

一些没用的细节:

  • sdk 必须要拦截,否则页面没法正确加载
  • 遇到 js 和 css 要改一波 baseUrl

API 代理前置内容 - MusicInject trait

这个 trait 是根据页面需要的 API 总结的,源码位于:src-tauri/src/global_struct/music_injector.rs

trait 实现了同步特征仅仅是为了方便在异步任务之间传递,根本不考虑安全性(逃。

总结出来的 4 个方法基本涵盖了音乐页所有的网页 API,但是动向和搜索等就还没有处理。

页面与相关 API 如下:

  • 页面首次加载 - 请求所有歌曲
  • 音乐页 - 请求所有专辑、请求专辑详情
  • 音乐播放页 - 请求歌曲详细信息

不过说实话 get_songs 这个方法在后续实践发现几乎用不上,因为原网页中这个方法是用来展示左上角那个小播放器的所有歌曲,且这个 api 全局仅调用一次,其次是如果真返回内容那性能真的很差(原网页里面的列表似乎都是没有虚拟列表的,其次在前端有一个方法是替换掉当前的所有歌曲列表,所有这个方法真的没什么用了)。

只要你的 MusicInject 实现了这个特征,即可进行自定义音乐注入操作。目前内置的所有所有注入都是实现了这个特征,包括本地音乐,播放列表等。

从 MusicInject 衍生出来的就是 MusicInjector,这个结构体包含了实现 trait 的一个对象和其他一些必要数据,详细的地方会放到插件章节讲解。

API 内容代理

api 相对麻烦点,插件也是从这里注入的。

内置的注入就是在 proxy 创建的时候手动创建。

由于原版网站的 id 都是纯数字,也就是说其他来源的歌曲不能用纯数字了,思来想去后决定用了一种比较好看的方式(误,来处理,即 来源:id。实践后也是可用的,还好在原网站没限定为只能用数字。

顺便说一下,rust 没有原生的正则表达式,所以现在用的是社区实现,据说性能很差,但是我正则比较短,所以不用特别担心。

处理 API 请求就是代理了 /song/:id/album/:id/songs/albums,如果没有匹配的就转发回原网站的 api 去,未来其实有计划添加 /albums/:namespace,这种 api,因为这样可以做到异步加载,假如拥有多个联网的注入器,如果直接请求 /albums 就可能会出现那种所有人都在等一个较慢的响应,导致体验 down down。但增加这个 api 也是后事了。

其余的似乎没什么好讲,基本上就是在匹配到请求时调用每个 injector 身上通过 trait 实现的方法,唯一讲的可能就是纯函数,但这也不算啥技巧了。

抽象的服务器Server

当时为了本地注入器花了不少时间,其中有一个点就是前端是直接获取音频URL的,当时脑子一热想的就是一个文件服务器,在请求参数里包含一个路径,服务器就会直接去读文件并返回。我一直好奇这里是否存在一个任意读取漏洞,包括可以读取任何隐私文件,所以标题才起的抽象服务器Server。源码在:src-tauri\src\server\file_server.rs

当然这个文件服务器读取也有问题,假如路径包含一些抽象名字也可能会发生读取失败的问题,这在背景切换也体现出来了,具体原因不清楚,未来看看有没有空修复吧。

对于从 URL 读取本地文件解决方案,我的下一步可选方案是:

  • 用 service worker 拦截请求,后端返回的时候直接返回文件路径,前端在 worker 里面拦截后调用 tauri app api 读取文件,这样也可以限定范围,读取完后用 blob 创建文件对象,然后将响应修改为 blob 的 url。

但不过以上这些方案似乎都不是很好,所以还是将就着吧,未来要修复的就是限定范围 + 处理特殊字符 + 缓存。

暂时没有启用的文件下载服务器

这里早期计划是满足断点传输这个需求。不过内容写好了,前端页面做好了,突然开始考虑起来版权问题,想着还是先放放,不过以后要是想练习 rust,说不定就会回来继续做了。

这里还可以设计的就是与前端通信,计划是设计如下事件:

后端到前端:

  • updateProgress - 返回需要更新的进度的歌曲数组 每首歌包含的信息是:下载状态、已下载进度、文件总大小,至于计算时间什么的交给前端吧。
  • downloadFinished - 有文件下载完成的通知
  • downloadFailed - 有文件下载失败的通知

前端到后端:

  • startDownloadSong - 开始下载指定的歌曲
  • startDownloadAll - 开始下载所有暂停歌曲
  • pauseDownloadSong - 暂停下载指定的歌曲
  • pauseDownloadAll - 暂停下载所有歌曲
  • removeDownloadSong - 移除指定的歌曲
  • removeDownloadAll - 移除所有歌曲
  • syncProgress - 获取完整下载列表进度

还有一个问题是任务数量限制,这个前后端实现都可以,只不过前端实现的话逆向比较简单,就可能随意突破限制了。后端也可以,但是难度就大一些,需要一点点写。

最后的问题是如何知道这是我们下载的文件?我想着是在文件元数据里面添加个标记,就这样吧(逃。

下载后的音频转码

不可能内联个 ffmpeg 吧,但是也没必要自己写。

找一些库垫上吧。

本地音乐注入器的一些抽象地方

核心代码写的有点长:src-tauri\src\vanilla_injector\local_music_injector\mod.rs

本地音乐这个也是写的有点那啥,简称屎山,但这里也是第一次尝试 tauri 的插件机制,主要是用来注入与前端通信的事件。

第一次处理音乐专辑 cid 的时候用的是 local:文件夹路径,结果在实际使用时发现如果路径包含斜杠就会导致专辑切换失败,不清楚前端为啥会有这样的问题,问yj去(逃。于是想了个抽象解决方法,就是把斜杠换成 :。有人可能会问这会不会影响正则运行,实践出来似乎没有问题,因为正则仅匹配第一个分号。

其次是音乐 id,用了一个 sha256,有人可能问为什么不用文件路径,因为我想着文件路径可以直接从专辑 id + 文件名进行拼接,所以就没必要再弄一次,但是 sha256 本身也是从路径算出来的,只能说整了不少抽象活。

在刚刚复盘的时候也注意到了一个问题,每次启动时似乎并不会刷新文件夹下面的歌曲,这个好解决,就是在应用启动的时候根据列表里的文件夹路径再调用一次 add_folder 方法就行了,因为底层是 hashmap,插入已有 key 的操作就是进行替换。

塞壬唱片原生注入

有人可能会问为什么还要另外开一个原生注入,回答是处理一些虚拟列表和特殊情况,在前端有一个选项是把所有塞壬唱片的音乐打平成一个列表,这个时候就需要一个虚拟播放列表了,但是原网站没有这样的操作,那就只能靠自己实现。

原网站在你播放指定的一首歌时,store 里面的播放信息也会跟着跳转,所以不能直接使用原生数字 id。

自定义播放列表

这里花的时间不是很多,主要纠结于如何设计数据结构,由于之前已经在本地音乐踩了很多坑,这部分做起来相对比较顺利。

这里解释一下为什么要生成一个 UUID 来指代歌曲,而不是直接使用原来的 ID,还是原网站的抽象问题,假如直接用原uid跳转至目标歌曲,那么专辑也会跟着切换,这个是原网站的操作,包含在源码内的,而且我也觉得没必要改(也许,因为未来还有那种临时播放列表可能要制作,这个时候就需要处理了。

然后就是存储的 brief 数据,不直接存储详细数据的原因是大部分在线音乐服务,他们提供的音频链接都是动态变化的,这通常是为了负载均衡吧,因此每次播放的时候肯定要获取新的音频链接,而不是直接用旧的。

当然这里做的不好的地方就是直接请求了底层 api,涉及到网络请求通常都会有很多副作用:src-tauri\src\vanilla_injector\custom_playlist_injector\manager.rs#L222

还有一个可以说的就是 customData 这个,他被用来设计为存储自定义数据,这里就是用作存储原播放列表的命名空间,当然其他注入器也可以用。只不过在原网页这些都是没效果的。

过程宏尝鲜体验

在 packages 下面有一个专门存储宏的包: packages\monster-siren-macro

目前这个包仅导出一个 command_ts_export 宏,是用来处理 tauri 包 下的 invoke 通信方法缺少补全的问题的,当然过程宏读写文件都是很低级的操作,相当的不稳定,如果真要维护则需要设计一套新的架构来处理。

这个宏目前只能处理返回值为 String 类型的类型,以及前端 invoke 时可以输入什么的提示,在打包过程你可以在控制台看见未知类型的输出:

ts 类型补全 macro 生成内容

其他地方的一些杂谈

音频缓存考虑在未来实现,会在 api 代理中进行增加。

下载功能一定要实现!

系统托盘这个似乎没法自定义,只能用预制的菜单做功能,好像 tauri v2 也没有更新,不知道有没有 tauri 领域大神提供解决方案。

插件

碎碎念

这个地方算是一个对 rust 的全新尝试,以前总是不知道 dll 是干嘛的,现在算是明白了。

不过我比较好奇的是 dll 是不是大部分情况都得写纯函数,或者加载的时候会有个必要的上下文环境?

策划与准备工作

早期策划是插件除了支持 rust 代码,也支持 nodejs 代码,没啥原因,就是因为想做的wyy插件有一个成熟的包,可以直接用。而 rust 没啥人做。

但既然要支持 js 代码就必须要有 node 环境,于是之前调研过能否在 dll 内打包一个 node 环境作为前置插件。试过了 pkg,发现不会(我菜);试过了 deno,发现他原来就是个类似 v8 的 runtime core,IO 操作要自己配库。受不了了,反正插件也是高级玩意,让用户自己装 node 去吧(逃。

操作手册都写得很详细,看完就会用了。

设计

每一个插件都独立享有一个 node 进程环境,缺点就是插件多了进程也跟着多,优点就是有隔离环境。

node 环境与 rust 环境通信你可以选网络或者 std 输入输出,由于笔者技术力选用的就是网络了,其次是wyy包本来就是开个 nodejs 服务器来服务。

没有 nodejs 重启机制,但是可以写,因为插件实例会存储整个 js 字符串,你也可以调用 dll 内方法获取。

接下来会围绕 ncm 插件展开聊聊插件。

ncm - node 层实现与打包

源码位于:packages\plugins\ncm_inject\src-node

笔者对于使用这个包是比较熟练的,且阅读过一部分源码,知道一些运作逻辑,所以会对插件内需求做出一定量的框架魔改实现。

源码内注释都很详细了,这里就谈谈打包问题。

最终打包出来的成品是一个完整 js,import 的地方仅有标准库,选用了 esbuild,单纯是够快,且配好了很多基础环境,如果使用 rollup 还可能需要配置大量的插件,相当的折磨(infra 建设的重要性)。

吐槽一下更新的注册匿名 token,其中里面的一步操作是读取包内的 deviceidText 文件,但是由于打包时仅处理 import 的语句,导致出了很多问题,于是自己改写了注册匿名 token 的接口来实现,只能说避免副作用操作还是很重要的。

还有一个是 wyy 登录页面,源码位于packages\plugins\ncm_inject\src-login\index.html,由于是 esbuild 直接打包出结果,于是在 esbuild 的 loader 给 html 设置了 text loader:

await esbuild.build({
  loader: {
    '.html': "text"
  }
})

这样返回的就直接是 html 字符串,express 直接返回读取后结果即可,打包后就更是直接内联在 js 文件中了。

当然这也就意味着你所有的内容都得写在 html 里面,如果要做更美观的页面,则还需要进一步定制,比如用 vite;插件内专门安排一个文件服务用来处理网页静态文件,但最终所有内容都得内联在一个 js 里。

未来个人认为静态文件还是要有个更好的处理方法,比如做成压缩包减小体积什么的,还有一些预制的常用包内联,比如 express,axios 等,直接内联到主应用,或者放到前置插件内,这样还有一些其他插件的他们都可以共用一份包代码,还能减小插件体积。

ncm - rust 层的实现

这一部分比较简单,就是将 node 层的接口管理一下,把他们收束到主包的 MusicInject trait 中去。

这里把所有收束行为都放到了 packages\plugins\ncm_inject\src-rust\src\injector\request_handler.rs。算是管理方便点吧。

源码里唯一注意的地方就是在 trait 运行的任何方法都要有 tokio 上下文,否则会报错,这个还是在 rustrover 调试的时候才发现的,vscode 直接闪退了,因此 handler 所有的方法都带有 tokio::main 注解。

ncm 的 id 比较特殊,他的格式是:ncm:<album_cid>-<song_cid>,这是我们将专辑作为播放列表,而网易云返回的歌曲信息并不会告诉你这被你自己的哪个歌单收藏了,所以需要手动指明原歌单 id。

同理,单元测试的时候也请务必把请求歌曲后歌单 cid 保持一致这一条测试加上去。

最后的暴露则是在 packages\plugins\ncm_inject\src-rust\src\lib.rs。暴露 init 函数,不过这有一个 warn 就是 not ffi safe,这个 warn 来源于 init 函数直接传递了 tauri::AppHandle 实例。

not FFI safe 解释如下(来源于 gpt4o):

在Rust中,FFI(Foreign Function Interface)用于与其他编程语言(如C)进行互操作。当你在Rust代码中看到”not FFI safe”的警告时,这意味着某些数据类型或函数签名在与其他语言进行互操作时可能会引发不安全的行为。

以下是一些可能导致“not FFI safe”警告的常见原因:

数据对齐:Rust中的某些数据类型在内存中的对齐方式可能与其他语言中的对齐方式不同。 非稳定ABI:Rust的ABI(应用二进制接口)不是稳定的,可能会在不同的编译器版本之间改变。 不安全数据类型:某些Rust数据类型(如Vec或String)在其他语言中可能无法正确解析。 生命周期和所有权:Rust的借用检查器和所有权系统在其他语言中并不存在,这可能导致未定义行为或内存泄漏。 为了确保FFI安全,你可以遵循以下几点:

使用#[repr(C)]来确保结构体的内存布局与C中的结构体相同。

使用基本的C兼容数据类型,如i32、u32、f64等。

避免在FFI边界上传递复杂的Rust类型,如Vec或String,可以使用原始指针或C兼容的类型代替。

确保在FFI函数签名中使用unsafe标记,因为调用外部函数可能会违反Rust的安全保证。

之前在 MusicInjector 上也有这个问题,好像是因为直接使用了一些复杂类型还是什么,后面改掉了就没了,但是这个 apphandle 就没办法了,这是包内结构。

不过在实际使用到现在都还没出问题,那先放着吧(逃。

前文提到的插件拥有控制完整 app 实例的权限就来源于 init 函数传递的 app 实例。由于插件权限过大,这也是我在操作手册中添加 仅安装受信任的插件 的警告的原因。

插件体积优化与打包速度优化

在早期插件没有那么多 rust 库时,插件的整个大小几乎都来源于内联 js,即 3MB 多一点,但是加上了 tokio 展开后,体积就上升到了 7MB。js 打包基本上很难再优化了,顶多再加个 br 体积优化,彻底消除任何冗余数据;rust 层面则是得研究 tokio 本身了,能否不加上注解来运行呢?但就笔者目前技术力是挺难做到的了,可能专门学 rust 的才有思路解决则会个问题。

其次是打包速度优化,js 方面没什么问题,esbuild 十分甚至九分的快。js 只需要管压缩冗余数据和内联就好了,而 cargo 要考虑的事情就很多了。由于早期没有调研充足与研究透彻的问题,现在打包插件所需的环境可能跟打包一个完整应用本体差不多,这经常导致我在没有缓存并进行打包操作的时候,电脑风扇都是转到起飞的那种,硬盘都在惨叫。

因此未来优化打包的方向如下:

  • 为主应用增加 feature 处理,减少插件打包时所需的源码。
  • 对包内 tauri 也是采用 feature 选择,减少需要的东西,比如什么 wry 之类的统统扔掉。

不过在有缓存的环境下如果单纯改变 js,那么重新打包一份插件也就 10 秒,改动了 rust 代码最多也就 1 分钟,所以有缓存后就好很多。

未来的展望

来不及了来不及了,就说说要考虑的东西吧:

  • 文件读写权限与范围,提供专门的 fs 方法
  • 沙箱环境运行
  • 给插件开个专门的线程或者进程,或者有一套合理的插件任务调度方案
  • 插件热重载
  • 实现其他语言的接口
  • 实现 node 环境装入前置插件
  • 实现一套可靠的生命周期系统与 hook 机制

这实现难度,再怎么说也得学rust再学个几年,开发几年吧(恼

总结

这个玩具算是对 rust + tauri 入门进行了一个尝试,但最终归根到底还是熟悉工程化以及如何做到长线维护和快速开发新需求的体验。

前端方面则是知道了副作用分离的重要性,以及开发一个组件需要知道的封闭性,隔离性。整个开发过程最有趣的应该是做组件,将组件一点点实现出来是很有成就感,当然 debug 过程也是最难受的地方。

rust 方面则是彻底的体验了一波所有权规则,生命周期反而倒是没怎么写,因为很多地方都用了拷贝(误。说实话初学 rust 的时候感觉这个所有权限制会很大,以为三行代码有两行要提示没法借用,实际上遇到这种情况的问题很少,只要你进行良好的组织,那就不会有太多错误。

先写这么多吧,来日方长,再更新也不迟。

个人信息
avatar
Shiinafan
文章
42
标签
52
归档
4
导航