Zed / GPUI 设计觉醒:现代编辑器 Action 机制解读

文章目录

我花了两周时间才参透了 Zed / GPUI 的 Action 设计,这是一个非常抽象且背景复杂的课题,攻克的过程一言难尽。由于缺少系统性介绍 GPUI 的文章和书籍,除了阅读源码,我也不得不借助 AI 辅助理解一些复杂的问题。但是,在这个问题上 AI 的表现非常糟糕,在几个关键问题上都给出了完全错误的解释。GPUI 的 Action 机制是在一种特定背景下提出的解决方案,这个背景隐藏在交互模式之下的复杂逻辑中,很难被识别出来。从开发人员的角度看,完成一个 Action 的注册和响应与完成一个 Event 的注册和响应在代码上几乎是一样的,完全体会不出两者的差别,而 Action 的应用场景也令人费解,它通常用于响应键盘输入,但在点击菜单和某些按钮时也会使用它。对于熟悉 Qt 的开发者来说,则会更加困惑,因为在 Qt 中也有一种 Action 的实现叫 QAction,它与 GPUI 的 Action 有很大的差异,这会对准确理解 GPUI 的 Action 造成干扰。本文,我们就把 GPUI 的 Action 机制彻底阐述清楚,而介绍 Action 机制离不开与它相对应的 Event 机制,所以,我们会将两者放在一起对比着介绍,这能让大家更清晰地看到 Action 所要解决的独特问题,本文为前四章试读内容,完整阅读地址:mp.weixin.qq.com/s/niCAW_mRS... 或 laurence.blog.csdn.net/article/det...。

1. Event 响应机制简介

基于 Event 的响应机制是每个 GUI 框架都会提供的核心功能,通常它被认为是:面向鼠标输入的响应模式。鼠标输入有一个显著的特点:抓住对象,直接操作 。当我们看到一个按钮可以直接点击它,看到分割条可以拖动它,这些在今天看来稀松平常的操作,也曾是计算机早期发展历程中,在交互模式上的一次重大创新,在学术领域有专门的理论,叫 Direct Manipulation:直接操作模式 ,它来自于 Ben Shneiderman 在 1983 年撰写的一篇论文《Direct Manipulation: A Step Beyond Programming Languages》,这一模式深刻影响了后来的 GUI 应用,我们今天所谓的"基于 Event 的响应模式"就是从这里发展而来,之所以要回溯这段历史,是要让大家记住基于 Event 的响应模式的本质特征:操作和操作对象一起产生。

在《Rust GPUI 桌面应用开发入门:界面交互和事件响应》一文中给出过一个 Counter 示例,它完整地展示了一个 UI 组件响应鼠标单击事件的全部编码工作:

① 在需要接收鼠标点击事件的 UI 元素上使用 on_mouse_up() 注册要监听的 Event 以及对应的响应函数。其中,方法的第一个参数是一个鼠标按键类型:MouseButton::Left,表示鼠标左键,它不是 Event ,MouseUpEvent 才是 Event。注册时,只需设定响应什么按键,无需使用 Event 来注册;在分发事件时,GPUI 会将事件中的按键和此处提供的按键进行匹配,即:event.button == button 以确定当前函数是否是对应的响应函数,所以这个"鼠标左键"参数相当于一个过滤条件。第二个参数叫 listener,其实这里需要的是一个函数,函数的参数和类型都通过类型约束定义好了,开发者需要在某处(一般是 Entity 的关联方法)实现这个响应函数,然后以函数项的形式传入,关于此处的 Rust 语法知识,可参考《一团乱麻?带你厘清 Rust 中的函数指针、函数项、fn 类型、Fn Trait》

② 提供一个用于响应事件的响应函数,通常,这个函数会定义为某个 Entity 的关联函数。受到 on_mouse_up() 对第二个参数,也就是传入函数的类型约束,这个事件响应函数的签名是规定好的,主要是三个参数:鼠标事件、&mut window 和 cx,除了方法要实现的业务逻辑外,如果在处理过程中需要改动 UI 组件或应用状态,可以通过传入的 &mut window 和 cx 去操作。

2. Action 响应机制简介

如果说 Event 机制是面向鼠标操作的响应模式,那 Action 机制就是面向键盘操作的响应模式。不过,Action 机制在学术上并没有什么理论起源,它是在编辑器软件的迭代发展中被提炼和完善出来的。GPUI 的 Action 机制不单单是使用键盘下达操作指令,它更多的能力体现在使用配置文件进行配置以及通过 Context 设置更精准地按键触发条件。Context 是 Action 机制非常重要的组成部分,最早由 Sublime Text 提出并实现,VS Code 中也有类似的功能叫 when 子句,GPUI 参考并提升了这些软件的 Context 设计。同样的,我们以《Rust GPUI 桌面应用开发入门:界面交互和事件响应》一文中的 Counter 为例,看一下如何让一个 UI 元素去响应键盘输入。整体上可以分成两个相对独立的部分:将组件纳入焦点系统和注册 Action。

2.1 将 Entity 纳入焦点系统

将 Entity 纳入焦点系统是 Action 响应机制的前置条件。实际上,伴随着本文后续的介绍,你会意识到,这并不是什么前置条件,而是 Action 响应机制中必须的一环,因为键盘输入的操作对象是靠焦点来确定的,如果 Entity 不在焦点系统中,就无法响应任何键盘输入。下图是 Counter 示例中完成 Entity 焦点注册的全部操作:

① 在创建 Entity 时,需要植入一个焦点句柄,因为在第 ③ 步注册焦点时需要提供这个句柄。

② Entity 需要实现 Focusable 这个特质,因为 GPUI 框架在焦点路径上寻找可响应的组件时,是用 Focusable 作为类型约束的,如果一个组件没有实现 Focusable 则在获取焦点时会遇到麻烦。

③ 在 UI 元素上注册 Entity 的焦点句柄,让 GPUI 沿着 UI 树上的"焦点路径"查找可响应的 Entity 时能找到它。

④ 在初始化窗口时需要创建 Entity 实例,此时需要传入一个焦点句柄的实例给到它,这个焦点句柄实例一般是从 cx 处获得的。

2.2 注册并响应 Action

完成焦点注册后,才可以进行 Action 的注册和响应操作。下图是 Counter 示例进行 Action 注册和响应的全部操作:

① 首先使用 actions! 宏声明所需的 Action。我们知道 Action 代表用户的操作意图,所以,Action 的命名要能体现出明确的"目的性"。

② 在需要响应 Action 的 UI 元素上使用 on_action() 注册响应函数。这里也不需要在参数中指定监听什么 Action,因为 GPUI 能根据注册的响应函数的第一个参数的类型自动推导出对应的 Action 类型。

③ 实现响应函数。通常,这个函数会定义为某个 Entity 的关联函数。跟事件响应函数一样,它们的函数签名也是被 on_action() 对参数的类型约束规定好的,也是三个:action、&mut window 和 cx。如果在接收到 Action 后需要获得更多环境信息,可以通过 &mut window 和 cx 获取。

以上,只是触发 Action 的一种途径,Action 的一个重要功能是:可以通过多种不同的途径触发同一个 Action。以 Reset 这个 Action 为例,在 Counter 示例中,我们还可以通过按下 r 键来触发它,而实现方法也是 3 步,和上面介绍的在 UI 元素上点击的实现整体上是一样的,只不过,在第二步时,Action 不是注册在 UI 组件上,而是绑定到了按键上,具体如下 :

① 同上

② 绑定按键要通过 cx 进行。Action 的捕获要依靠焦点系统,所以如本节开头所说:要让 Action 生效,必须让组件(Entity)先具备获取焦点的资格。

③ 同上

3. 人机交互和约定用语

接下来,我们要分别将 Event 和 Action 的响应流程推导一遍,这是找出 Action 设计定位的必要一环,但在这之前,需要储备一点人机交互的理论知识,同时对后续论述中用到的一些表述做一些约定,以确保全文一致。

使用鼠标和键盘向软件下达操作指令是我们非常熟悉的日常,但是,我相信绝大多数人都没有认真思考过这个过程是怎样发生的,这其实是计算机领域里的一个重要学科,叫:人机交互(HCI),这里面其实有很多学问,有一些是我们没有意识到但对理解 Event 和 Action 机制非常重要的因素。

在交互过程中,人与计算机之间存在通信,通常涉用两种语言:用户到计算机的方向涉及各种交互设备;而计算机到用户的方向主要通过显示器传递给人的眼睛,当然也可能包含声音或触觉等组成部分。这两种语言各自的意义(meaning)和形式(form)构成了自然的抽象边界:我们必须确定用户可以向计算机传达什么信息(意义),以及每种信息通过什么方式来传达(形式);反过来也同样如此。此外,还有第三个组成部分:交互设备与显示器之间(其实就是 GUI 应用/框架内的处理逻辑)的关系,或者说,将输入转换为输出中某种有意义内容所需要的数学方法或算法。

以上引用自《Computer Graphics: Principles and Practice》 第 21.2 节,我们讨论的 Event 和 Action 响应机制就会涉及"用户 🠚 计算机"和"交互设备 🠚 GUI 应用"这两个层面。

其中,在"用户 🠚 计算机"这个层面发生的事情可以简单概括为:用户通过鼠标和键盘两种输入设备将操作和操作对象输入给计算机,然后计算机在内部(也就是 GUI 应用内部)找到"操作对象"然后执行对应的"操作"。这里,我们正式提出"操作"和"操作对象"这两个术语,与"操作"含义接近的表述还有:命令、指令,与"操作对象"含义接近的表述还有:目标、对象等,如果你有自己的习惯用语,请在此自动对齐,后续我们将统一使用"操作"和"操作对象"这两个术语。

其实,人通过输入设备跟一个计算机软件进行交互是一个很抽象的过程,这个过程要将人的"意图"转换成应用软件中的一个"响应方法",这个过程经不住细想,想得越多往往就越迷惑。在人机交互理论中,针对这个问题的解释有一个著名的四层模型理论,是由 Foley 和 Van Dam 在 1982 年出版的 《The Design of User-Computer Graphic Conversations》一书中提出(寻找原始出处的过程犹如一场计算机科学考古,是 AI 把我引入了歧途):

它的核心思想是:把人机交互从高层用户意图到底层设备输入拆成"概念层 🠚 语义层 🠚 语法层 🠚 词法层"四个独立层级,每层只负责一类问题,实现关注点分离。不过,这并不是我们理解 Event 和 Action 响应机制的一个必要知识点,实际上,我们可以用下面这种更通俗的方式来理解鼠标和键盘的交互:

  • 在鼠标交互中:输入 = 操作 + 操作对象

    • 操作对象:

      • 在界面上时指的是用户点击的那个 "UI 组件";
      • 在应用中时指的是那个 UI 组件在内存中的 "对象";
    • 操作:

      • 在界面上是时指用户的 "点击鼠标";
      • 在应用中时是指内存中操作对象上的一个 "方法";
  • 在键盘交互中:输入 = 操作

    • 操作对象(并不通过按键指定,而是由焦点提供):

      • 在界面上时指的是焦点停留的那个 "UI 组件";
      • 在应用中时指的是焦点停留的那个 UI 组件在内存中的 "对象";
    • 操作:

      • 在界面上是时指用户 "按下组合键";
      • 在应用中时是指内存中操作对象上的一个 "方法";

关于操作在应用中的映射物:内存中操作对象上的"方法",如果是直白的设计,那它就应该是一个 UI 组件的成员方法,然后由开发人员根据操作意图实现里面的逻辑,不过,很多 GUI 框架都会优化这一部分的设计,通过事件委托模型(一种设计模式),将操作请求委派给另一个业务对象的方法去处理,此处,我们忽略这一细节,从逻辑上说,在应用中,操作就是操作对象上的一个方法。

4. Event 响应流程推导

从用户的操作意图上讲,当人点击一个按钮时,就是希望它去执行一个操作,这是"按钮"这种 UI 组件的领域职责,人们引入按钮这种组件时就是这样设想的,所以,一个按钮 = 一项操作,点击一个按钮 = 执行一项操作。当一个按钮被按下时,它应该天然得知道自己要做什么,所以,按钮要有一个方法,这个方法里的逻辑就是"它应该知道要去做的事",也就是"操作"的具体实现,不管是用领域驱动设计还是朴素的面向对象建模,都会得出一样的结论。在这样的逻辑驱动下,基于 Event 的响应过程大致是这样的:我们以《Rust GPUI 桌面应用开发入门:界面交互和事件响应》一文中的"点击 Reset 按钮"这个操作为例,在按下鼠标键的那一刻,会产生两个物理信号:

1. 物理信号:鼠标左键点击

2. 物理信号:鼠标指针坐标

基于鼠标的交互有一个显著的特点:输入会携带操作对象 ,通过鼠标指针坐标,GUI 框架可以确定点击的是哪一个 UI 组件。这一交互特点对 Event 机制产生了深刻的影响,因为被指向的组件就是用户意图的"执行者",用户之所以"指"它,就是要让它来执行操作,这直接决定了 GUI 框架必须在程序内存中找到被指的对象。

当两个物理信号输入操作系统,再进入到应用中后。GUI 框架的任务是:解读这些输入信息然后转换成一次函数调用。处理和解读这些输入信息的工作其实非常重要,包含坐标转换、事件合并、命中测试、遍历 UI 树等一系列工作。其中,命中测试是一个非常重要的环节:人点击一个按钮,在现实世界中是一个很直观的操作,但输入到计算中的只是"鼠标左键点击"和"鼠标指针坐标"这种物理信号,GUI 框架需要根据这些物理信号确定是哪一个"按钮"被点击了,这里的"按钮"指的是位于 GUI 应用内存中的"按钮对象",找到这个对象几乎是整个事件响应流程中最关键的一个环节。GUI 使用的定位方法叫命中测试(Hit-Testing),它会基于点击时鼠标指针的坐标和应用窗口的尺寸与位置进行几何计算,从而判断出点在了哪个对象上。经过 GUI 框架处理后,原始的输入信息被转换成了内存中两个确定的对象:

1. 内存对象:Event(它是 GUI 框架基于按键、坐标等输入信息封装而来)

2. 内存对象:Button(它是 GUI 框架基于输入信息进行命中测试找到的被点击按钮在应用内存中的形态)

然后,GUI 框架会根据 Event 在 Button 上找到与之对应的响应函数,然后调用它,整个事件响应流程就结束了。但是,大多数 GUI 框架都不会直接这样实现,因为,这个响应函数显然是要由用户来编写的,而 UI 组件一般是 GUI 库中的固化类型,一方面用户很难直接在上面添加或重写一个方法,另一方面,即使可以这样做,也不适合在 UI 组件里编写业务代码,于是很多 GUI 框架选择了一种设计模式:事件委托模型(Event‑Delegation Model),它是观察者模式的一种改良版本,它不需要事件源自己亲自处理事件,而是把事件 "委托" 给注册的监听器对象处理,其实就是一种回调机制。所以,完整的响应流程如下图所示:

由于 GUI 中的很多处理工作都较为底层,且无需用户干预,所以,它们会被框架封装起来,对用户不可见。用户如果缺失了对这一部分工作的了解,就很容易对 Event 模式产生困惑。其中最常见的一个困惑就是:作为这个模式下领域建模的核心业务实体:Event 的存在感往往很弱,在编程时人们很少会用到它,大多数情况下,它的用处是在注册响应函数时作为一个 key 出现,以便区分同一个 UI 组件对应不同输入(mouse left up / left down / ...)的响应方法是哪一个。导致这种奇怪现象的原因是:人们只看到了 Event 这个"结果",并没有感受到产生 Event 的"过程",在见到 Event 之前,GUI 框架已经完成了大量的后台工作,作为框架的终端用户很难意识到这一点,我们可以把这些在背后发挥作用的功能称为:隐式全局状态 & 机制。这些幕后工作并不是单纯为了生成一个 Event,更重大的意义在于通过这个 Event 可以找到与之对应的响应函数并执行它,这才是 Event 的意义所在。

作为事件委托模型的一环,Event 和响应函数是通过注册才关联在一起的,这会发生在 on_mouse_up() 这类注册方法上(GPUI 使用的是鼠标按键注册,而非 Event,两者作为 Key 是没有差别的),这一步操作的效果就是将操作对象和最终的响应函数通过 Event 关联起来,在 UML 中,Event 是一个典型的 Qualifier:.....

以上为前四章节试读内容,完整内容请移步:mp.weixin.qq.com/s/niCAW_mRS... 或 laurence.blog.csdn.net/article/det...

相关推荐
wflynn6 小时前
GitHub 今日推荐|lightcraft:纯 Rust 重写的 RAW 照片开发工具
rust·开源·github·lightroom·art·photography
geovindu6 小时前
rust: Flyweight Pattern
开发语言·后端·设计模式·rust·享元模式·结构型模式
golang学习记1 天前
rust开发,选VS Code还是RustRover?
开发语言·后端·rust
滕州市燕猫虎计算机科技工作室个体工商户1 天前
《Rust程序设计》学习笔记二
rust·学习笔记
滕州市燕猫虎计算机科技工作室个体工商户1 天前
《Rust程序设计》学习笔记三
rust·学习笔记·rust程序设计
geovindu1 天前
rust: Facade Pattern
开发语言·后端·设计模式·rust·外观模式
福大大架构师每日一题1 天前
Rust 1.99.0发布:C 可变参数、裸函数、Cargo 配置、Rustdoc 性能与大量兼容性调整全解析
c语言·开发语言·rust
DongQiShanRen1 天前
裁决台账双向互校(下):台账哈希链、三向对账与最小落地
人工智能·深度学习·算法·目标跟踪·自然语言处理·rust·哈希算法
行者-全栈开发1 天前
华为云码道 CodeArts 实测:让 AI 用 Rust 写一个 Git 仓库健康度体检台「仓衡」
git·rust·tauri·桌面应用·ai 编程·华为云码道·codearts 代码智能体