分类目录归档:技术

OpenPAL3:做一个开源版本的仙剑三

《仙剑奇侠传三》是我小时候最喜欢的游戏之一了(另一个是《新剑侠情缘》),沉迷神鬼妖怪世界无法自拔,只有发烧卧床不起才能阻挡我玩游戏的热情。ummm 当时发烧算是原因之一吧,另一个重要原因是玩到锁妖塔四层过不去了_(:з」∠)_

前一阵大概是因为偶然刷到 OpenDiablo2 让我突然想到,为什么不来做一个 OpenPAL3 呢?让自己喜欢的游戏重新活一次,也是很有意思的一件事。如果顺利的话,说不定还能扩展一点私货,比如做个战棋战斗模式?不过这都是后话了,得先把基本的原始游戏功能做出来才行。

所以我就踏上了收集当前前沿科技进展的旅途。首先发现的是 @zhangboyang/PAL3patch 这个仓库,虽然名字叫仙剑三分辨率补丁,其实还包含了相当多的其他功能。最关键的是,这里提供了一个便捷的脱壳工具,为我们分析程序扫除了障碍,可行度++。

另外一个值得一提地方是仙剑三高难度贴吧。贴吧不愧是互联网中文社区的扛把子,这个高难度吧里面聚集了很多做仙剑三 mod 的神犇,全都是人肉编辑二进制的资源文件,拷来改去还能不出错。贴吧里有一些分析目录结构的帖子,是入门上手的好地方。还有一些关于游戏机制和数值的分析,但对文件格式的解析比较少见。

另外还搜罗到几篇零散的文章,其中一个是看雪论坛的一篇帖子,分析了仙剑三剧情文件的格式以及压缩包的压缩格式。另一个是一个转贴,原博客已经年久失修找不到了,这个帖子简单分析了仙剑三的一种模型文件的格式,不过尚不全面。最后一个是开源的仙剑三解包工具,实际试用一下,还有一些 Bug,解压出来的文件可能会有不正确的地方。

最后的最后,是藏在仙剑三游戏资源里面的意外惊喜。解包出来的文件当中,有个别文件是游戏开发过程中误打包进来的,比如剧情脚本的源代码(一种非常简单的类似于汇编的语言),还有少许说明性的文本文件,不过提供的信息并不是很多。

这些就是目前巨人肩膀的高度了,剩下的就得靠自己了。首先是先定一个小目标,否则步子太大容易扯到蛋:目标就定为能够播放游戏开头景天跟雪见登场的那一小段剧情好了。

为了实现这个目标,我们得需要:

  • 本着自娱自乐的原则,引擎肯定是要自撸的,所以至少得有一个能凑活用的引擎
  • 模型文件格式的解析,能加载模型是最基本的要求了
  • 场景文件格式的解析,得能把景天的小屋子搭出来
  • 剧情脚本文件可以暂时不用完全读出来,毕竟那一小段剧情不长,把剧情脚本硬编码一下也是可以的

大概就需要这么多的工作吧,这样就可以实现第一个小目标了。从过年前后开始开工算起,到现在大概做了两个半月了,如果从撸引擎开始算要更早一点——现在我已经完成第一个小目标啦!虽然这篇文章发在四月一号,但这不是愚人节忽悠,不是愚人节忽悠,不是愚人节忽悠!

https://zhuanlan.zhihu.com/p/122532099

各位放心,你们的耳机没有坏,是我还没有加声音;文本框也是临时用 Imgui 默认的框框对付了一下,立绘表情也还没贴,场景的环境光也还没有设置(本来应该是烛光烁烁的夜晚😅)……目前还只是 Demo 的程度,非常多的硬编码,相当多的技术债,还有很多文件格式上的问题没有弄清楚。不过不管怎么说,小目标算是勉强达成了,就把它叫做 v0.1 吧。

在达成小目标期间,也是要做一点辅助工具的,比如模型浏览器:

永安当场景静态模型
景天 – 人物顶点动画

场景浏览器:

渝州西南

那么接下来的第二个小目标的话,就定为能够跑出永安当吧。要达成第二个小目标,需要解决的问题非常多,输入控制、声音、碰撞、事件触发机制还都没做,大概没有几个月搞不定,道阻且长 _(:з」∠)_

仓库在此,欢迎 Star 🧐 https://github.com/dontpanic92/OpenPAL3​

Rust 面向对象 FFI

说到 FFI,FFI to C 大概算得上是事实标准了。Rust 也提供了 to C 的 FFI,但如果我们想把 Rust 结构包装成对象提供给其他语言使用,事情就没那么简单了。


一种方法是完全依赖于源语言的对象模型,典型的例子是 SWIG。SWIG 是一个 C/C++ 的 Wrapper Generator,通过 parse C/C++ 头文件、结合不同的后端,可以输出不同语言的绑定。它的思路是比较简单的:C++ 中的成员方法全部包装为 extern C 的普通函数,接收 C++ 对象的指针;目标语言这边的对象只携带一个 C/C++ 对象的指针就可以了。我之前用 SWIG 做过一个 Wrapper,wrap 了 wxWidgets(一个 C++ 库)给 golang 用,比较头疼的是:

  • 内存管理。一般的 C++ 类都不会自带引用计数,这时候怎么处理裸指针就很头疼,每个函数的返回值和参数都需要(人工)分析出对象的所有权是否转移,以便决定需不需要在 Go 这一侧调用 delete。同时,如果所有权在 C++ 一侧,如何判断对象是否已经释放,仍然比较难办。如果 C++ 一侧的库完全使用智能指针还好一些,但这件事情高度自由,不可强求。
  • ABI 问题。上面提到了这种方法依赖于源语言的对象模型;但不仅如此,我们还依赖于编译器所使用的 ABI。例如,如果库是 gcc 编译的,wrapper 也需要是 gcc;甚至 gcc 的 Library ABI 的变化,也会使我们受到影响。
  • SWIG 的前端后端都是在一起的,中间表示就是 AST,除了直接加代码也无法自行扩展。如果要做 wrapper,最好是能够自己控制 wrapper generator。比如,Go 语言不支持重载,那 C++ 的重载怎么办?如果我们想要的与 SWIG 的不同、或是有自己特殊的情况需要处理,就需要自己 fork 了——这时候,所有与后端不相干、但又不得不知道的东西,就会成为负担。
  • (改 SWIG 的那个 Go 后端真的很难受

目前 Rust 似乎还没有 SWIG 这样工具,只找到一个 cbindgen,可以生成 C/C++ 头文件。C++ 头文件的话,比较有特色的是能够支持模板,但 impl 之类的还是无法放进 class 里面去。


另一种方法是,在源语言一侧,先手工包装并导出为一个标准的、跨语言的对象模型。谈到跨语言的对象模型,跟我有过交集的也就两个:一个是 COM,一个是 GObject。

先聊一下 GObject 吧,由于用过 GtkSharp,对 GObject 多少有一些了解。Rust 的 Gtk 绑定 gtk-rs 提供了方便手段,可以让我们在 Rust 里面 subclass GObject 类型,但这还是不满足我们的需求,因为它并不能支持在 Rust 中定义并导出新的类型给其他语言使用。

能够实现这个目的的,是一个叫做 gnome-class 的库。(作者居然是 Gnome 的 Co-Founder 😮)它能够生成 GObject 规则的 C 语言导出函数,同时可以生成 GIR 文件(GObject Introspection Repository)。

GIR 描述了 GObject 的类型,很多动态类型语言都有 gobject-introspection 库的支持,它能够直接读取经过编译后的 GIR(叫做 typelib)。也就是说,只要有了 GIR,理论上不再需要任何的其他步骤就可以在 Python/Ruby/Lua/Javascript 里面使用了我们的库了。

对于静态类型的语言,还是需要有代码生成的步骤的。不过一般来说,只要这门语言有 Gtk3 的绑定,多半都会有代码生成器的,比如 Rust/Haskell/D 之类的语言。

我实际试了一下这个库,目前还远远谈不上可用,但在几经 hack 之后终于能够正常在 Python 里面 Subclass 一个 Rust 的 struct 了:

Rust 一侧,我们需要把 Wrapper 写在一个 macro 里。class/virtual 什么的语法稍稍显得有点奇怪;还有一点点我 hack 的痕迹:

gobject_gen! {
    #[generate("generated/application.rs")]
    #[generate("generated/application.gir")]
    class Application: GObject {
        application: GiApplication,
    }

    impl Application {
        pub fn initialize(&self) {
            self.get_priv().application.0.borrow_mut().callbacks_mut().g_object 
                = <Self as glib::translate::ToGlibPtr<'_, *mut ApplicationFfi,>>::to_glib_none(self).0;
            self.get_priv().application.0.borrow_mut().initialize();
        }

        virtual fn on_updated(&self, _delta_time: i32) {
            println!("in application on_updated");
        }

        pub fn run(&self) {
            self.get_priv().application.0.borrow_mut().run();
        }
    }
}

生成了 GIR 之后,使用 g-ir-compiler 把它编译为 typelib,就可以直接在 Python 里面导入了:

from gi.repository import Application

class App(Application.Application):
    def __init__(self):
        super().__init__()
   
    def do_on_updated(self, delta_time):
        print("delta_time " + str(delta_time))

a = App()
a.initialize()
a.run()

至于 COM,最常见的就是 DirectX 的 API 了。可能大家觉得它是 Windows-Only 的技术,但其实 COM 作为 Object Model 来说仍然是跨平台的。微软自己有一个 com-rs,还有一个支持跨平台的 intercom。大概是因为 COM 全部都是接口导出,要比 GObject 简单一点,所以只需要在 Rust struct 上加一个 Attribute Macro 就可以了。这是一个 intercom 的例子,相比于上面的 gnome-class 简直轻松了一个档次:

pub use intercom::*;

#[com_library(Calculator)]

#[com_class(Calculator)]
struct Calculator {
    value: i32
}

#[com_interface]
impl Calculator {
    pub fn new() -> Calculator { Calculator { value: 0 } }

    pb fn add(&mut self, value: i32) -> ComResult<i32> {
        self.value += value;
        Ok(self.value)
    }
}

还是有不少语言支持 COM 的,至少 .net 上的语言都会比较容易;其他的比如 D 也支持直接与 COM 组件交互。


一大圈下来,似乎导出为 COM 用起来最方便,只是在目标语言一侧支持力度没有 GObject 那么大,也不知道实际用起来有没有坑。FFI 终究是一件很繁琐的事啊😥

当 Neovim 遇上 Acrylic

dotnvim 是很久之前开的坑了,当时是想做一个颜值在线的 Windows neovim 前端。但其实我很少单独使用 vim,用也多半是在别的 IDE 里面用 vim 插件,所以这个项目也没怎么更新过;而且新的 Windows Terminal 完善之后,大概 dotnvim 存在的意义又下降了一分。但今天又打开看了一下,发现颜值其实还是挺不错的,居然又燃起了我间歇性使用 vim 的欲望⚠顺便再写一遍文章回忆一下当时开发时遇到的坑🤔

没有可用的 C# neovim 客户端

虽然当时在 neovim Wiki 的 Related Projects 里面有列出一个 C# 客户端,但实际看过就会发现功能欠缺很多,几乎处于不可用的状态。所以只好自己再造一个轮子了。neovim 与前端的通信使用的是 message pack,是一种很容易理解的协议。整个客户端的实现也很简单,就是 neovim 说什么,我们照做就行了——比如光标移动到哪里、颜色设置成什么、显示什么字符之类的。

但是只看 neovim 的 文档 还是会不明所以,所以搞不懂的时候就只能去翻官方的 Python 版或者是 Qt 版的客户端代码了。

Acrylic Blur

一开始我是打算做成 UWP 的,毕竟亚克力效果只有 UWP 才支持。起初考虑 nvim 需要自己单独一个进程,担心 UWP 不支持,结果是我多虑了。但后来翻了一下 UWP 的权限问题,似乎保存文件到任意位置的唯一方法只有弹出一个文件选择框,这与 vim 用户们的习惯大相径庭。最后就还是决定做成一个 Win32 的程序。

这样一来,如何实现 Acrylic Blur 就是个问题了。不过好在当时已经有很多关于 Windows 未公开的 API SetWindowCompositionAttribute 的资料了,所以自己琢磨一下试试也不难。(画外音:Windows 的兼容性负担就是被你们这群人搞上去的😀

当时还有很多采用自绘的方案,但跟原生的亚克力效果还是无法做到一致的。至于如何让整个窗体全都被透明特效覆盖到,这就属于 DwmSetWindowAttribute 和 DwmExtendFrameIntoClientArea 的工作了,这两个 API Aero 的时候就有了,幸亏以前用过,上手速度++。

Winform 还是 WPF?

本来准备使用 WPF,还在 WPF 和 D3D 交互上搞了半天,用了这个看起来没人维护了的 WPFDXInterop,它提供了一个 D3DImage 控件,只要把渲染好的图贴上去就可以了。但是最关键的是用它搞不定透明,如果在 WPF 里通过 WinformHost 的嵌入一个 Win32 控件的话,透明效果又不对。

所以最后我选择直接就使用一个 Win32 的窗体,上面所有的内容全部自己用 D2D 画,反正主体界面也不复杂,顶多就是几个 Button、再加上一个简单的 BoxLayout。至于其他的对话框(比如设置),就直接使用 WPF 就可以了。

1px 的不和谐边框

由于 Windows 10 上窗口周围都有 1 px(设备无关像素) 的边框,当我们的亚克力背景透明度调的比较低时,这个 1px 的边框就会露出来。这个问题实在无解,最后我就暴力地用 vim 当前的背景色不透明地先把这 1px 画上,盖住系统的边框,至少颜色上与当前使用的 Color Scheme 能够统一。如果我们的背景没那么透明,这样的边框就不显眼了。

就是这个黄色边框,其实是为了盖住系统的边框

次像素抗锯齿

这是我一直都没有搞定的一个问题。由于我们窗口的背景是透明的,次像素抗锯齿没办法启用,只能使用灰度抗锯齿。但灰度抗锯齿在低分辨率屏幕上的效果并不是很好。

我还特意去看了一下 UWP 的抗锯齿效果,也不是次像素抗锯齿(放大之后没有明显的红绿蓝边界),但好像要比普通的灰度抗锯齿的效果要顺眼(难道是错觉?)。好在我用 Surface,灰度抗锯齿效果在高分辨率屏幕上还不错,眼不见心不烦😞

连字 Ligature

怎么在 D2D/DirectWrite 上启用连字也花了我不少的时间。连字就是现在很多人喜欢的那种把多个字符(比如->、==)画在一起的那个功能。

要实现连字,首先需要通过 TextAnalyzer 分析输入的文本,得到一组 Glyph 的索引,以及一组 Cluster Map。Cluster Map 里面村的就是 Code Point 到 Glyph 直接的对应关系。比如,如果 Cluster Map 的内容是 [0, 1, 1, 2, 4],那就代表第 1、2 个 Code Point 对应了一个 Glyph(比如 Ligature 的情况),而第 3 个 Code Point 对应了 2 个 Glyph (比如阿拉伯文上面的各种修饰)。

 Codepoint Index    Glyph Index
       0  -----------   0
       1  -----------   1
       2  ----------/
       3  -----------   2
          \----------   3
       4  -----------   4

最后使用 DrawGlyphRun,把几个 Glyph 一起画出来。如果字体提供了连字支持,那么在 Cluster Map 中就会有所体现。

C++/CLI 做胶水

有时需要跟 Native 的 Win32 API 做交互,这个时候就会感叹有一个 C++/CLI 这样的语言做胶水实在是太方便了。但 C++/CLI 的 Bug 也不少,有时会遇到报编译器内部错误,建议我把 XX 行附近的代码换个写法🤣 现在在 dotnet core 的趋势下,大概 C++/CLI 历史任务也快圆满完成了,可是替任者在哪呢?

关于这个项目的一些回忆大概就这么多了。最后还是要感慨一下,跟调原生 DirectX API 相比, 用 SharpDX 确实舒服很多,这可能跟 C# 的语法糖们也有关系8️⃣。

Redo Build System

前一阵子想找一个简单的 build system,看到了 redo,就去研究了一下。一开始还有点迷糊,但最后发现,虽然 redo 看起来只做了一件事(当文件变化时重新执行脚本),但它其实还解决了另一件更重要的事:如何在一个命令式语言中描述结构信息?

声明式语言是擅长描述结构信息的,谁是谁的父亲、谁是谁的兄弟、谁依赖了谁、谁的属性是什么等等。Makefile 就是一种典型的声明式语言,大多数 build system 也都使用声明式语言来描述构建流程(也有的使用了同时掺杂了命令式和声明式的语言,比如 Meson)。

回到一开始的问题,如何在命令式语言中方便地描述这种结构信息?熟悉函数式编程的人大概一下子就会想到使用 Method Chaining:

target("all")
    .depends_on("other_target")
    .source_files("a.c", "b.c")
    .call("...")
    ....;

虽然很多语言都能模仿出这种写法(你甚至可以用 bash 写 fp),但这已经不算是典型的命令式语言的用法了。同时,它仍然是“提前声明”式的做法(即在 build target 之前声明好这个 target 的所有属性、依赖等等),不够灵性。Redo 的做法同时也解决了 Makefile 的一些其他问题,例如依赖的其他 Make 文件是在编译过程中产生的情况、或是 Recursive Make Considered Harmful

Redo 的做法其实很简单,就是让脚本一边跑,一边收集脚本或文件之间的依赖关系。这是它与其他声明式语法的 build system 的最大不同之处。这样的好处是,build 过程仍然用 bash 描述(比起用 Method Chaining 自然很多),而且依赖关系可以随时添加——甚至在编译结束之后。在命令式语言里看起来这是再正常不过的事了:

# 依赖 $2.c
redo-ifchange $2.c
gcc -MD -MF $2.d -c -o $3 $2.c
read DEPS <$2.d

# 依赖 gcc 生成的头文件列表中的所有文件
redo-ifchange ${DEPS#*:}

稍微跑个题,我很久之前其实也想过类似的问题:如何在 C++ 中描述一个图形界面?这个问题与虽然跟 Redo 半毛钱关系没有,但解决的思路其实完全一样。最后我做到了这样:

这是完全合法的 C++ 代码,扔进编译器就能编过。它描述了 main_form、button1 和 button2 之间的结构关系(父子关系),看起来像是声明式的语法,但仍然不丧失命令式语言的特点。最后只要:

int main()  
{
    yz::app app;
    yz::window* w = yz::ui["main_form"];
    app.exec();
    return 0;
}

就能启动。实现细节请看 https://zhuanlan.zhihu.com/p/24196189


至于如何看待 Redo,我觉得它小众自然有它的原因。一个 build 脚本,目标的依赖关系方面使用声明式语言更容易表达,而每个目标的产出过程是一个命令式的过程。所以路无非三条:可以在声明式的语法中加上点命令元素,或是在命令式的语法中加上点声明元素,或是干脆搞一个半声明半命令式的语言。Make 走的第一条路,Redo 是第二条,Meson 是第三条。

从结果来看,大家明显认为解决目标之间的依赖问题更重要。

什么是 CLR ?

作者:By Vance Morrison – 2007
原文链接:https://github.com/dotnet/coreclr/blob/master/Documentation/botr/intro-to-clr.md
翻译:dontpanic

什么是公共语言运行时(Common Language Runtime, CLR)?简单来说就是:

公共语言运行时(CLR)是一套完整的、高级的虚拟机,它被设计为用来支持不同的编程语言,并支持它们之间的互操作。

啊,有点绕口,同时也不太直观。不过这样的表述还是 有用的 ,它把 CLR 的特性用一种易于理解的方式分了类。由于 CLR 实在太过庞大和复杂,这是我们理解它的第一步——犹如从万米高空俯视它,我们可以了解到 CLR 的整体目标;而在这之后,我们就可以带着这种全局观念,更好地详细了解各个子模块。

CLR:一个(很少见的)完备的编程平台

每一个程序都有大量的运行时依赖。当然,一个程序需要由某种特定的编程语言编写而成,不过这只是程序员把想法变成现实的第一步。所有有意义的程序,都免不了需要与一些 运行时库 打交道,以便能够操作机器的其他资源(比如用户输入、磁盘文件、网络通讯,等等)。程序代码还需要某种变换(翻译或编译)才能够被硬件直接执行。这些依赖实在是太多了,不仅种类繁多还互相纠缠,因此编程语言的实现者通常都把这些问题交由其他标准来指定。例如,C++ 语言并没有制定一种 “C++可执行程序” 格式;相反,每个 C++ 编译器都会与特定的硬件架构(例如 x86)以及特定的操作系统(例如 Windows、Linux 或 macOS)绑定,它们会对可执行文件的格式进行描述,并规定要如何加载这些程序。因此,程序员们并不会搞出一个 “C++可执行文件”,而是 “Windows X86 可执行程序” 或 “Power PC Mac OS 可执行程序”。

通常来说,直接使用现有的硬件和操作系统标准是件好事,但它同样也会把语言规范与现有标准的抽象层次紧密捆绑起来。例如,常见的操作系统并没有支持垃圾回收的堆内存,因此我们就无法用现有的标准来描述一种能够利用垃圾回收优势的接口(例如,把一堆字符串传来传去而不用担心谁来删除它们)。同样,典型的可执行文件格式只提供了运行一个程序所需要的信息,但并没有提供足够的信息能让编译器把其他的二进制文件与这个可执行文件绑定。举例来说,C++ 程序通常都会使用标准库(在 Windows 上叫做 msvcrt.dll),它包含了大多数常用的功能(例如 printf),但只有这一个库文件是不行的。程序员如果想使用这个库,必须还要有与它相匹配的头文件(例如 stdio.h)才可以。由此可见,现有的可执行文件格式标准无法同时做到:1、满足运行程序的需求;2、提供使程序完整所必须的其他信息或二进制文件。

CLR 能够解决这些问题,因为它制定了一套非常完整的规范(已被 ECMA 标准化)。这套规范描述了一个程序的完整生命周期中所需要的所有细节,从构建、绑定一直到部署和执行。例如,CLR 制订了:

  • 一个支持 GC 的虚拟机,它拥有自己的指令集(叫做公共中间语言,Common Intermediate Langauge),用来描述程序所能执行的基本操作。这意味着 CLR 并不依赖于某种特定类型的 CPU。
  • 一种丰富的元数据表示,用来描述一个程序的声明(例如类型、字段、方法等等)。因此编译器能够利用这些信息来生成其他程序,它们能够从“外面”调用这段程序提供的功能。
  • 一种文件格式,它指定了文件中各个字节所表达的意含义。因此你可以说,一个 “CLR EXE”并没有与某个特定的操作系统或计算机硬件相捆绑。
  • 已加载程序的生命周期语义,即一种 “CLR EXE 引用其他 CLR EXE” 的机制。同时还制订了一些规则,指定了运行时要如何在执行阶段查找并引用其他文件。
  • 一套类库,它们能够利用 CLR 所支持的功能(例如垃圾回收、异常以及泛型)来向程序提供一些基本功能(例如整型、字符串、数组、列表和字典),同时也提供了一些与操作系统有关的功能(例如文件、网络、用户交互)。
继续阅读

长模式与 16 位程序

这是我在知乎的回答:https://www.zhihu.com/question/310785889/answer/58670983

首先我们要把 16 位程序分成两类来看:跑在实模式下的 16 位程序和跑在保护模式下的 16 位程序。长模式是支持兼容 16 位的保护模式的(x86-64 白皮书):

但由于在长模式下移除了虚拟 8086 模式,无法运行实模式的(直接访问硬件的)16位程序。

至于为什么移除了虚拟 8086 模式,只能认为是不想再多背包袱了。已经有的包袱甩不掉,但至少别给自己再找新麻烦…

其实 Intel 在 80286 添加保护模式时就不想带这个包袱了,它没有提供直接的方法从保护模式转换回实模式(即无法支持实模式 16 位程序) 。也许是大家怨声载道,而后在 386 中 Intel 添加了虚拟 8086 模式,允许直接在保护模式下虚拟一个实模式出来。这样在 32 位操作系统中,实模式的 16 位程序才能运行。所以到了 64 位,这样的包袱不再扔一次怎么行呢,实在不行再把它加回来就好了,毕竟这事 Intel 已经干过一回了 🤣

总而言之,长模式是可以运行 16 位程序的,但只兼容保护模式。不过 Windows 也无法运行 16 位保护模式下的程序,这是 Windows 本身不再支持了的原因,不是长模式的限制。

错误模型 – The Error Model

  • 原文链接:http://joeduffyblog.com/2016/02/07/the-error-model/
  • 作者:Joe Duffy
  • 翻译:dontpanic

译注:下文中所有的“我”、“我们”均指代原作者 Joe Duffy 或其所在团队。作者总结了目前主流编程语言中常见的错误模型的优缺点,同时分别给出了自己的针对不可恢复错误(Unrecoverable Error)和可恢复错误(Recoverable Error)的处理方案。由于本文有一些惯用语和专业词语,受限于我的水平,可能出现翻译错误、或措辞与主流方案不同等问题,欢迎指出以便修正。本文较长,请做好长时间阅读的准备。

译注:前情提要:原作者 Joe Duffy 曾在微软参与开发一款操作系统 Midori,这是一款研究型/孵化型项目。这款操作系统主要由一种 C# 的变种语言(有人称作 M# 语言)编写。

The Error Model

Midori 使用了一种基于 C# 的、支持 AOT 编译、类型安全的语言。除了我们的微内核,整个系统都是使用这种语言编写的,包括驱动程序、域内核(Domain Kernel),以及全部的用户代码。我在这段时间里收获了很多,现在是时候总结一下了。整个语言涵盖的东西太多了,我会分成几篇文章来阐述,就先从错误模型开始。错误(Errors)的传递与处理在任何编程语言中都是非常基础的部分,而对于用来编写高可靠操作系统的语言来说更是如此。就像 Midori 项目的其他部分一样,任何修改一部分都应该从全局的角度来考量,并进行不断地迭代。我经常从原来的同事那里听到说,错误模型是他们在 Midori 上开发程序时最怀念的部分。我也很怀念这部分。那么废话少说,我们这就开始。

错误模型简介

错误模型需要回答的最基本的问题是:“错误”应该如何传达给程序员和用户?这问题似乎很简单。

要回答这个问题,最大的阻碍之一是:如何定义什么是“错误”。很多语言把 Bug 和可恢复错误归为一类,用同样的机制来处理,比如把空指针引用错误、数组访问越界错误与网络连接错误、语法分析错误混为一谈。这种一致性初看起来很美好,但它有着深层次的问题——这种一致性会造成很大误解,并且通常会导致程序员写出不可靠的代码。

总的来说,我们的解决方法是同时提供两套错误模型。一方面,对于程序的 Bug,我们提供了快速失败(Fail-Fast)模型,在 Midori 中我们称其为放弃(Abandonment);另一方面,对于可恢复的错误,我们也提供了静态受检查异常(Statically Checked Exception)。这两种错误模型从编程模式到背后的机制都截然不同。放弃会无条件地立即终止整个进程,不会再运行任何用户代码(需要说明的是:一个典型的 Midori 程序会由很多个小的、轻量级的进程组成);异常则允许恢复用户代码的执行,在这个过程中类型系统会起到重要的检查和验证的作用。

我们的旅程漫长而曲折。为了讲好这段故事,这篇文章分为以下6个主要部分:

  • 野心和经验
  • Bugs 不是可恢复错误!
  • 可靠性、容错性和隔离性
  • Bugs:放弃(Abandonment)、断言(Assertions)和合约(Contracts)
  • 可恢复错误:类型导向的异常
  • 回顾与总结

现在看来,有些结论似乎很明显,尤其是在更现代的系统语言
(比如 Go 和 Rust)出现之后。但是一些结论还是让我们很惊讶。我会尽量省略废话,但也会提供足够的背景故事。我们走过很多弯路,但我觉得这些弯路甚至要比最终的结论更有趣。

继续阅读

WebApp for Desktop: 请不要滥用手型指针

这是一篇吐槽。最近想用Electron做点东西,大致浏览了几个UI库,又想起一些用Electron做的App的糟糕体验,实在是想吐槽一番。也不知道大家是不是也有类似的感觉,还是只是我个人吹毛求疵。如果是我的问题,还请打醒我。

首先,这里的WebApp指的是用基于Web的技术制作的客户端程序,比如VSCode、Microsoft Teams、Github Desktop等等。我在使用VSCode和Microsoft Teams时,在用户体验上会跟NativeApp有严重的割裂感。除了渲染性能这种客观问题之外,最主要的问题是,手型指针被滥用了

到处都是手型指针!

举例来说,在VSCode中,把鼠标放在一切能够点击的东西上,几乎都会变成手型,比如文件列表、文件Tabs、各种按钮等等:

然而,在主流的Windows/Gnome/KDE/macOS上,这些都不应该触发手型指针:

为什么在WebApp里面不应该大量使用手型指针?

因为滥用手型指针违背了各种Native UX设计指南——即,这就不是Native App的Feel。例如,在微软的Windows Desktop UxGuide中,明确说明了普通指针和手型指针的适用情况:

Normal Select – Used for most objects.
Link select – Used for text and graphics links because of their weak affordance.

在苹果的Human Interface Guidelines中,同样明确说明了普通指针和手型指针的适用情况:

Arrow – This is the standard pointer that’s used to select and interact with content and interface elements.
Pointing hand – The content beneath the pointer is a URL link to a webpage, document, or other item.

总结一下就是:只有在文本图片链接等情况下,才会推荐使用手型指针。所有一般情况,都应该使用普通的指针。

虽然手型指针为用户提供了额外的提示,表示这个元素可以被鼠标操作,但是在Native App中,很多时候不需要、也不应该依靠手型指针来增强操作提示。在微软的Windows App UxGuide中,有这样一段话:

Well-designed user interface (UI) objects are said to have affordance, which are visual and behavioral properties of an object that suggest how it is used.

也就是说,UI元素应当使用一些视觉和行为属性来表示它支持的操作——例如按钮应当做成看起来就可以被按下的样子、Slider应该有个槽槽来表示它可以被滑动,等等——而不是使用手型指针来提示这些操作。例子就像上面给出的Windows 资源管理器,以及QtCreator的侧边栏。

但为什么我不反感在普通网页中大量使用手型指针?

这里我也没有想的很清楚,可能的原因有:①在使用浏览器浏览网页时,我不期待网页会有Native的Look’n’Feel;②习惯了!

不过,我觉得主要的原因还是由于网页与客户端程序存在区别:

网页的本质是一篇文档。当我浏览一篇网页时,跟看一本杂志、看一本书很像。因此,网页上的交互组件应该优先与文档的整体风格保持一致,而不是优先显得“affordable”(不知道怎么翻译,可操作性?)。一个看起来就能够按下的按钮,且不说风格问题,更有可能喧宾夺主。所以我们可以弱化这些元素的affordance,而使用手型指针来增强操作提示。(说实话,很多Web UI Component的按钮,我就没什么按下去的欲望。)

然而客户端程序不是文档,尽管我们依然使用Web的技术来构建它,但它不是一篇文档。它的功能性更加重要,各类UI元素就是界面的主体。所以应该把各类UI元素在视觉上就设计得足够affordable,而不是去借助手型指针。上面贴出的VSCode中的各种button,有的甚至连hover效果都没有!

正例:Github Desktop

Github Desktop是我想举出的正例之一。它的下拉菜单、按钮、列表等等,全部使用普通鼠标指针,使用起来非常愉快:

结尾

其实除了手型指针这个问题之外,有些App还有一些小地方不够Native,比如Microsoft Teams中的一些图标存在延迟加载问题。在用Web技术做移动App时,大家都在往Native Look’n’Feel 靠拢;为什么到了Desktop,却不在意这些体验呢?

最后如果大家知道哪个UI库不滥用手型指针的,请推荐一个……

附:可以参考的其他讨论

https://ux.stackexchange.com/questions/105024/why-dont-button-html-elements-have-a-css-cursor-pointer-by-default

在 Medium.com 上查看