简介
Book of Irisia GUI是包含了Irisia GUI框架的学习使用、深入了解和参与开发的文档合集。
为什么选择irisia?
高性能和高表现力
irisia采用winit作为窗口启动器,是rust最热门的窗口启动器之一。
irisia采用skia作为渲染后端。skia是由谷歌公司开发的跨平台图形库,应用于Chrome浏览器和Android原生渲染。经过长时间的考验,其稳定性和性能都十分出类拔萃。除此之外,skia拥有非常完善的字体渲染模块和绘制功能,使得irisia能在您的屏幕上呈现更多精彩画面。
跨平台
得益于winit
和skia-safe
良好的跨平台性,irisia可以跨桌面端和移动端。对Windows、Linux和Android平台的支持是我们的工作重心。
缓存
如果您阅读了前一章节,您或许会注意到各种语法结构。请不用担心,我们将会对任何语法结构——顺序、选择和循环结构中所有元素进行缓存,使每一帧都以最小开销渲染。
或许您之前使用过React框架,与之不同的是irisia的循环语句可以储存任意可以被HashMap作为键的结构体,但是强制您提供一个键。尽管这在for
语句中,@key
指令并不是必须的,那是因为在未指定@key
时,默认使用迭代器中的元素作为键。
异步系统
irisia内部分为两种工作模式:同步代码和异步代码。
如果您不是一个组件开发者,您将很小概率和同步代码部分打交道。同步代码大多作用在渲染部分,它要求不能等待,必须高效渲染每一帧。
相比之下,异步代码几乎全部作用在事件处理部分,您可以将等待事件和其他如文件系统和网络等异步IO更好地协作,这样能很好地提升工作效率,并避免回调地狱(Callback hell)。
高度自定义
irisia允许您最大限度地自定义您的元素,您可以自定义元素布局、组件和样式。用户是可以还原出标准库中的文本框TextBox
的。
在具有高自定义度的同时,我们也同样重视开发效率。如果您只想利用现成的模块快速构建您的应用程序,请多多熟悉build
和style
(以及render_fn
)宏吧!
丰富的宏
irisia很大程度上利用了rust的元编程系统。如前面您所见的整个库的核心宏build
和style
。除此之外,也有专门用于自定义样式的派生宏Style
等。
use irisia::{Style, Pixel, skia_safe::Color}; #[derive(Style)] #[irisia( from = "x_offset, y_offset[, blur_radius][, spread_radius][, color]", impl_default )] struct StyleBoxShadow { #[irisia(default)] x_offset: Pixel, #[irisia(default)] y_offset: Pixel, #[irisia(default)] blur_radius: Pixel, #[irisia(default)] spread_radius: Pixel, #[irisia(default = "Color::BLACK")] color: Color } fn main() { let style = style! { box_shadow: 0px, 0px; box_shadow: 0px, 0px, 10px; // ... }; }
设计原理
学习Irisia GUI的设计理念有助于您理解Irisia GUI。
main函数
下图展示了一个Irisia程序的入口函数工作例程。
程序开始 -> 启动运行时 -> 创建窗口 ------ ----> 退出
^ |
|---------
很简单,对吧?
main函数要做的工作就是创建一个或多个窗口,然后等待窗口关闭,最后退出。在这里没有什么要点。
渲染周期
当一个窗口初始化完成后,需要提供一个根元素,然后进入第一个渲染周期,循环。 一个窗口会接收3种事件:
- 更新树事件:以30次每秒速度触发。当接收到该事件后,会重构结点树,检查缓存。如果元素更新缓存,则向窗口请求重绘。
- 重绘事件:最高以120fps更新,取决于计算机性能。当更新节点树事件检查到更改后,会请求重绘。同时含动画元素也可以申请动画帧(animation frame),而独立于更新树事件,达到更流畅的动画效果。
- 窗口事件:鼠标事件、键盘事件等。它可以以高于120fps的速度刷新,取决于当前电子设备的硬件。
征集开发者!
我可以做什么?
如果您没有使用GUI框架
那么您理解本项目可能需要花费一定时间,因为文档工作目前十分不完善。不过,如果您对GUI框架感兴趣,欢迎持续关注本项目,也可也向我(或我们)提问任何有关该项目的问题!
如果您使用过GUI框架
或许您是新手,或身经百战的老手,我们都希望您能够过目一下本项目examples目录下的window.rs
示例,并试着了解一下我们的开发思路以及目前进度。请继续往下阅读,欢迎提出各种意见、改进措施、评价,您的任何意见对我们来说都十分宝贵!
我希望加入开发
首先感谢并欢迎您的加入!
现状
目前Irisia GUI尚处于早期阶段,优化和文档都不尽人意,也许还存在bug。这需要您理解Irisia GUI框架的设计理念,请向我提问任何您不理解的问题。
截至撰写这篇文档时,没有任何其他开发者。
以下几点是Irisia GUI目前未完工的部分:
- GPU性能优化。目前测试运行窗口时GPU占用率过高,需要优化。
- 平台移植。目前Irisia GUI是在Windows平台上开发和测试,理论上可应用于其他系统,但仍需进一步测试和兼容性改造。
- 动画。目前Irisia GUI原生不支持动画,虽然是可以做出来的,但是这样写出来的代码风格不尽人意。
- 预制类。目前Irisia GUI预制只有文本框。极度缺乏样式,控件和布局生成器。
- 文档和翻译。目前文档大部分都是中文,而且没有注释。
我和Irisia的故事
这里只是叙事文,没有什么资料在这里,可以跳过。
Irisia GUI的诞生
撰写这篇文档时是2023年4月份,我是一名大二本科生,8年前开始自学编程,2021年接触Rust。由于我很早开始接触电子游戏Minecraft(我的世界),因此受到其影响,走上编程道路。在一开始,我通过编写JSON文件使游戏中的生物做出各种动作开始,慢慢接触了JS,接着进入Web的领域,利用手边的各种框架(有Vue开发经验)和JS库开发与Minecraft相关工具,包括皮肤制作器ShadowSkin和联机工具MCMU,这些都可以在我的GitHub个人主页找到。经过几年和Web端开发,我逐渐理解了GUI的设计理念和网络编程的方式。这些经验为我开发Irisia GUI打下了设计方向的基础。
时间来到2021年的春天,经朋友介绍认识了Rust这门语言。当我开始试着了解它时,就和它的编程理念一拍即合,于是开始了Rust的学习生涯。由于我是从Web端“转行”到Rust,因此花费了半个多月才具备设计Rust程序的能力。之后经过长达一年的磨合,我通过Rust异步编程开发了MCMU的Rust版本——MCMU2,后来迭代出改进版的MCMU2-alpha版本。借助Rust的力量,MCMU的性能和功能都逐渐增加,但是也面临着一个重要的问题——没有图形界面。
起初我并没有打算自己做GUI,而是放眼crates.io去寻找合适的GUI框架,但却收获寥寥。我尝试了一些比较热门的GUI框架,例如Iced,Druid,imgui甚至tauri,但都不能令我满意。在经过长时间考察后,我决定亲手设计一个GUI框架,这就是Irisia GUI。
准备工作
在开始着手设计Irisia GUI前,刚开始我必须选择一个图形库。因为当时Rust生态中的图形库功能参差不齐,本身也尚未成熟,甚至连阴影盒子的功能都没有得到良好的支持,所以我没有局限在纯Rust开发的图形库中。我开始寻找FFI方案,很快发现Skia具有良好的跨平台性、强大的功能以及方便易用的Rust绑定。我设计了一些简单的测试程序,在由winit启动的窗口上,测试Skia在Windows平台和Android平台的绘制正常后,定下来使用Skia作为图形库后端。
紧接着我需要解决窗口启动器的问题。虽然我认可winit的跨平台性,但是它仍缺失一些关键功能,例如在Android端弹出虚拟键盘和启用剪贴板功能。于是我在winit的repo中提了相关issue,得到了基于jni弹出虚拟键盘和利用copypasta库获得剪贴板功能的解决方案。前者运行良好,但是后者却缺少移动端支持。于是我模仿基于jni弹出虚拟键盘的方案,很快实现了对安卓剪贴板操控方式的支持方案,并提交了Pull Request。项目管理者十分相中这个实现方式,但由于仓库代码的格式化和rustfmt默认格式不同,最终没有成功Merge到主分支,后续我也没有再关注。但可以确定的是,这两个方案运行良好,解决了刚需问题。
在我试着兼容winit的同时,并没有放弃尝试别的窗口启动器。我尝试了sdl2的方案,发现它的功能和winit近似。在测试跨平台性时,Windows端不需要过多干预即可运行,而Android端就没有那么幸运了。为了能在Android端编译运行,我学习了如何使用Gradle,利用插件对Rust工程打包,但是过程比较繁琐,这也是后来没有选择sdl2的原因。最终,sdl2带动skia在Windows端和Android端成功运行。但是,又因为rust绑定的sdl2的文档比较缺乏,维护不足,因此最终放弃了该方案。
安装环境
接下来,请选择一个您想要编译到的平台
Windows环境
安装Rust
请跟随官网指引,安装Rust。
将irisia引入您的项目
创建一个新项目,并进入该目录
> cargo new my-first-app
> cd my-first-app
打开Cargo.toml
文件,并在项目的[dependencies]
下添加依赖。由于该库尚未正式发布,因此仅能通过git链接进行安装。
irisia = { git = "https://github.com/Fancyflame/irisia-gui.git" }
然后等待安装完成。
如果安装过程中skia-safe
编译错误
有时因为各种原因,skia-safe
无法正常编译。在本文中,推荐使用预编译包,这样您无需安装skia编译环境并等待编译。如无法下载预编译包,请检查网络连接是否正常。若仍无法下载,请采取手动下载方式,如下文。
- 进入
skia-binaries
发布页 - 下载最新预编译包,格式为
skia-binaries-<编号>-<架构>-pc-windows-msvc-textlayout.tar.gz
,如skia-binaries-4f106aa048fa92fce6ce-x86_64-pc-windows-msvc-textlayout.tar.gz
- 将压缩包复制到合适的文件夹,并选择下列任意一种方法
- (推荐)在系统搜索栏中搜索
PATH
,选择编辑系统环境变量,新建环境变量SKIA_BINARIES_URL
,值为file://<目录>\skia-binaries-{key}.tar.gz
,例如file://C:\path\to\directory\skia-binaries-{key}.tar.gz
。注意,{key}
要保留源文本 - 启动文件服务器1,将上面步骤的值改为
<服务器地址>/skia-binaries-{key}.tar.gz
,例如http://127.0.0.1:8000/skia-binaries-{key}.tar.gz
- (推荐)在系统搜索栏中搜索
- 重启您的控制台,或设置同样的环境变量,使得您的控制台能够识别您新设置的
SKIA_BINARIES_URL
环境变量 - 重新编译一次
如果出现skia链接错误
原issue rust-skia#660 主要原因是构建工具MSBuild版本过低,比如您使用的是MSBuild 2019。您可以参考以下更新方案:
- 访问https://visualstudio.microsoft.com/zh-hans/visual-cpp-build-tools/获得MSBuild安装程序
- 启动安装程序,安装最新Visual Studio构建工具
- 点击“修改”,打开“单个组件”
- 勾选最新MSVC生成工具,例如
MSVC v143 - VS 2022 C++ x64/x86 生成工具(最新)
- 勾选最新Windows SDK,例如
Windows 11 SDK (10.0.22621.0)
- 点击“修改”,等待安装完成
- 重新编译一次
推荐选用nodejs或python文件服务器。如果您选用的是nodejs服务器,输入http-server C:\path\to\directory -p 8000
来启动(需安装http-server
)。如果您选用的是python服务器,输入python -m http.server -d C:\path\to\directory -p 8000
来启动。
第一个窗口
创建一个自定义元素
打开main.rs
,修改为下面内容
#![allow(unused)] fn main() { use irisia::Element; struct MyApp; impl Element for MyApp { // ... } }
现在,我们将要为MyApp
实现Element
trait。
首先,我们使用irisia::render_fn
宏来为我们自动实现render
函数。
#![allow(unused)] fn main() { use irisia::{Element, render_fn}; #[derive(Default)] struct MyApp; impl Element for MyApp { render_fn! { @init(self); } } }
render_fn
宏的语法和build
语法几乎完全相似,唯一区别就是@init
函数。可以在这里找到build
宏的详细用法。由于rust十分注重宏的卫生性,所以用户必须手动提供self
关键字使self可访问。当然,您也可以使用任意有效标识符,例如@init(foo)
,它们会编译为fn render(foo: &mut Self, ..) ..
。
然后,我们添加一个文本框。
#![allow(unused)] fn main() { use irisia::{ textbox::TextBox, Element, render_fn, element::{NeverInitalized, NoProps}, }; #[derive(Default)] struct MyApp; impl Element for MyApp { render_fn! { @init(self); TextBox { text: "hello world" } } fn create(_: irisia::element::RuntimeInit<Self>) -> Self { Self{} } type Props<'a> = NoProps; type ChildProps<'a> = NeverInitalized; } }
这样,我们的第一个元素就设计完成了。
添加main函数
我们现在需要创建一个窗口让它运行起来。我们添加一个程序入口, 让它启动一个窗口来将我们的元素作为根元素渲染。
use irisia::{ textbox::TextBox, Element, Window, render_fn, }; #[derive(Default)] struct MyApp; impl Element for MyApp { render_fn! { @init(self); TextBox { text: "hello world" } } } #[irisia::main] async fn main() { Window::new::<MyApp>("my first app").await.unwrap().join().await; }
点击运行即可看到一个标题为my first app
的窗口中渲染的hello world
字样了。
您可能会疑惑,MyApp
是一个零长度的结构体,那么有缓存吗?是的,这也是有缓存的。render_fn!
会帮我们接收一个&mut CacheBox
参数,我们定义的TextBox
,以及后续您添加的其他元素都会一并缓存在这个缓存盒里,由一个名为AddChildCache
的结构体代理。为了让用户专注于应用设计,我们没有让用户接管这部分。但是请注意,如果您选择手动实现render
方法,请不要用CacheBox缓存不同类型的元素,这样会导致debug模式下程序panic,release模式下大量性能开销和元素创建。
总而言之,如果您使用render_fn
宏,请放心,irisia能够保证妥善保管元素的缓存。
build语法
build
宏可帮助您快速构建一个节点树。
@init
指令
@init
是提供给build
或render_fn
宏基本参数的指令。一个宏最多只能有一个@init
指令,而且必须出现在开头位置。
- 在
build
宏中,@init
指令是可选的,可接受0~2个参数。语法为@init[([<EventDispatcher>[, <Slot>]])];
。例如:@init;
:静态节点。@init(event_dispatcher);
:将监听元素创建事件。@init(event_dispathcer, slot);
:将监听元素创建事件,并且可使用一次@slot
指令。
- 在
render_fn
宏中,@init
指令是必须的,只能接受1个参数。语法为@init(<ident>)
。例如:@init(self);
:等同于fn render(&mut self, ..)..
。@init(foo);
:等同于fn render(foo: &mut Self, ..)..
。
声明元素
声明一个元素分为以下几部分:
<元素名> {
(
<属性名>: <表达式>,
+<元素指令>: <表达式>,
)*
<子节点列表>
}
这是一个带有text
和select
属性的TextBox
元素。
#![allow(unused)] fn main() { TextBox { +id: ("text_box", 12345), text: "this is a &str", select: true, } }
元素内指令和属性的不同之处在于它是以+
开头的。目前,只有两个指令,style
和id
。其中,style
指令可向该元素提供一个实现了StyleContainer
trait的结构体,id
则将表达式的值作为该元素的标识符,监听该元素的元素创建事件。关于这两个指令将在后面章节给出。
下面,来学习一下顺序结构、嵌套结构和拓展指令。
顺序、嵌套和扩展
学习了如何声明一个元素,这篇章节将十分利于理解。
顺序结构
直接向下排列即可,不能加逗号。
#![allow(unused)] fn main() { build! { Element1; Element2; Element3 { props: "value" } } }
嵌套结构
可以通过这种方式声明子元素。可添加独立的花括号来分割您的内容。
#![allow(unused)] fn main() { build! { Div { Div { Element1; } { Element2; Element3 { props: "value" } } { Element4; Element5 } } } }
拓展指令
可以通过@extend <表达式>
语法来将其他元素树扩展到当前元素树。扩展的树允许多元素。
#![allow(unused)] fn main() { let ext = build! { Element1; Element2; }; let branch = build! { @extend ext; Element3 { props: "value" } Element4; }; }
插槽指令
如果在build
的@init
指令中提供了插槽(Slot),或使用render_fn
宏,则可以使用@slot
指令来将插槽中的所有元素合并到当前树中。它和手动@extend <插槽表达式>
的效果是一样的。
#![allow(unused)] fn main() { render_fn! { Element1; Element2; @slot; } }
选择结构
选择结构允许您根据条件选择表达式。它们会拓展为一个enum
结构体,以储存不同类型的表达式。if和match表达式和rust的语法非常相似,除了表达式是元素声明语句。
if表达式
#![allow(unused)] fn main() { build! { if 1 + 1 == 2 { Element1 { props: "1 + 1 == 2 is true" } } if 1 + 2 == 3 { Element1 { props: "1 + 2 == 3 is true" } } else { Element2 { other_props: "oh no, something wrong happened!" } } } }
match表达式
#![allow(unused)] fn main() { build! { match 20 { 1 => Element1 { props: "value is 1" }, num if num % 2 == 0 => Element2 { other_props: "value can be exact divided by 2" } _ => {} } } }
循环结构
循环结构可以让您生成多个相同类型的元素。但为了得到高效的缓存,循环结构中必须提供键。
键是用来确定每一个数据对应的元素缓存的。如果不使用键,那么一个迭代器中如果元素发生中间插入、中间删除、调换顺序,则会导致一连串元素的属性需要重新设置,进而可能导致大量元素需要改变其缓存,造成性能损耗。
下面展示了如果没有键,插入一个新数据的过程。
elements 1 2 3 5 6 7
| | | | | |
data 1 2 3 5 6 7
^
|
插入4
所有数据对应的缓存将会后移一位,并在末尾创建一个新元素。
elements* 1 2 3 5 6 7 new
| | | | | | |
data 1 2 3 4 5 6 7
再更新缓存。显而易见,这样的操作改变了这4个元素的缓存。
|------------|
elements 1 2 3 | 4 5 6 7 |
| | | | | | | | |
data 1 2 3 | 4 5 6 7 |
|------------|
如果使用键,则第二步将变为:
在此插入新元素
|
v
elements* 1 2 3 5 6 7
| | | \ \ \
data 1 2 3 4 5 6 7
^
|
插入4
(new)
elements 1 2 3 4 5 6 7
| | | | | | |
data 1 2 3 4 5 6 7
这样,一次只需改变(初始化)一个元素的缓存就行了。
for表达式
for表达式中,@key
指令是可选的。如果不指定@key
,则默认使用迭代器元素作为键。这要求迭代器元素实现Clone + Hash + Eq + 'static
,若不满足,则引起编译期错误。此时,需要您通过@key
指令手动指定键。
#![allow(unused)] fn main() { build! { for num in 0..10 { Element1 { props: num } } } }
等同于
#![allow(unused)] fn main() { build! { for num in 0..10 { @key num; Element1 { props: num } } } }
下面这个例子将会引发编译期错误
#![allow(unused)] fn main() { let vec = vec![1, 2, 3]; build! { // 错误:num不满足`'static` for num in vec.iter() { Element1 { props: *num } } } }
解决办法是指定@key
,或改为iter().copied()
。
#![allow(unused)] fn main() { let vec = vec![1, 2, 3]; build! { for num in vec.iter() { @key *num; Element1 { props: *num } } } }
#![allow(unused)] fn main() { let vec = vec![1, 2, 3]; build! { for num in vec.iter().copied() { Element1 { props: *num } } } }
while表达式
与for表达式不同的是,while表达式必须指定键。
#![allow(unused)] fn main() { let mut iter = vec![1, 2, 3].into_iter(); build! { while let Some(num) = iter.next() { @key num; Element1 { props: num } } } }
如果您不知道指定什么键,请考虑下面的方案。这将回归到本章开始部分的“无键缓存方案”。
#![allow(unused)] fn main() { build! { for _ in (0..).take_while(|_| expression_here) { Element1 { props: 0 } } } }
style语法
声明样式
声明样式的语法为
<样式类型>: [<参数>, ..][.<选项> [<值>], ..];
为了方便起见,当一个样式在宏内是以snake_case方式1被声明时,将被解析为CamelCase,然后在头部添加“Style”。例如“width”将被转义为“StyleWidth”。
snake_case是一种命名规则,用于将多个单词连接成一个单词并用下划线分隔。在snake_case中,单词以小写字母表示,并使用下划线分隔,例如:my_variable_name。而在rust中,类型应该以CamelCase的命名方式出现。
动画
正在开发中...