简介

Book of Irisia GUI是包含了Irisia GUI框架的学习使用、深入了解和参与开发的文档合集。

为什么选择irisia?

高性能和高表现力

irisia采用winit作为窗口启动器,是rust最热门的窗口启动器之一。

irisia采用skia作为渲染后端。skia是由谷歌公司开发的跨平台图形库,应用于Chrome浏览器和Android原生渲染。经过长时间的考验,其稳定性和性能都十分出类拔萃。除此之外,skia拥有非常完善的字体渲染模块和绘制功能,使得irisia能在您的屏幕上呈现更多精彩画面。

跨平台

得益于winitskia-safe良好的跨平台性,irisia可以跨桌面端和移动端。对Windows、Linux和Android平台的支持是我们的工作重心。

缓存

如果您阅读了前一章节,您或许会注意到各种语法结构。请不用担心,我们将会对任何语法结构——顺序、选择和循环结构中所有元素进行缓存,使每一帧都以最小开销渲染。

或许您之前使用过React框架,与之不同的是irisia的循环语句可以储存任意可以被HashMap作为键的结构体,但是强制您提供一个键。尽管这在for语句中,@key指令并不是必须的,那是因为在未指定@key时,默认使用迭代器中的元素作为键。

异步系统

irisia内部分为两种工作模式:同步代码和异步代码。

如果您不是一个组件开发者,您将很小概率和同步代码部分打交道。同步代码大多作用在渲染部分,它要求不能等待,必须高效渲染每一帧。

相比之下,异步代码几乎全部作用在事件处理部分,您可以将等待事件和其他如文件系统和网络等异步IO更好地协作,这样能很好地提升工作效率,并避免回调地狱(Callback hell)。

高度自定义

irisia允许您最大限度地自定义您的元素,您可以自定义元素布局、组件和样式。用户是可以还原出标准库中的文本框TextBox的。

在具有高自定义度的同时,我们也同样重视开发效率。如果您只想利用现成的模块快速构建您的应用程序,请多多熟悉buildstyle(以及render_fn)宏吧!

丰富的宏

irisia很大程度上利用了rust的元编程系统。如前面您所见的整个库的核心宏buildstyle。除此之外,也有专门用于自定义样式的派生宏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种事件:

  1. 更新树事件:以30次每秒速度触发。当接收到该事件后,会重构结点树,检查缓存。如果元素更新缓存,则向窗口请求重绘。
  2. 重绘事件:最高以120fps更新,取决于计算机性能。当更新节点树事件检查到更改后,会请求重绘。同时含动画元素也可以申请动画帧(animation frame),而独立于更新树事件,达到更流畅的动画效果。
  3. 窗口事件:鼠标事件、键盘事件等。它可以以高于120fps的速度刷新,取决于当前电子设备的硬件。

principle picture

征集开发者!

我可以做什么?

如果您没有使用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编译环境并等待编译。如无法下载预编译包,请检查网络连接是否正常。若仍无法下载,请采取手动下载方式,如下文。

  1. 进入skia-binaries发布页
  2. 下载最新预编译包,格式为skia-binaries-<编号>-<架构>-pc-windows-msvc-textlayout.tar.gz,如skia-binaries-4f106aa048fa92fce6ce-x86_64-pc-windows-msvc-textlayout.tar.gz
  3. 将压缩包复制到合适的文件夹,并选择下列任意一种方法
    • (推荐)在系统搜索栏中搜索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
  4. 重启您的控制台,或设置同样的环境变量,使得您的控制台能够识别您新设置的SKIA_BINARIES_URL环境变量
  5. 重新编译一次

如果出现skia链接错误

原issue rust-skia#660 主要原因是构建工具MSBuild版本过低,比如您使用的是MSBuild 2019。您可以参考以下更新方案:

  1. 访问https://visualstudio.microsoft.com/zh-hans/visual-cpp-build-tools/获得MSBuild安装程序
  2. 启动安装程序,安装最新Visual Studio构建工具
  3. 点击“修改”,打开“单个组件”
  4. 勾选最新MSVC生成工具,例如MSVC v143 - VS 2022 C++ x64/x86 生成工具(最新)
  5. 勾选最新Windows SDK,例如Windows 11 SDK (10.0.22621.0)
  6. 点击“修改”,等待安装完成
  7. 重新编译一次
1

推荐选用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是提供给buildrender_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, ..)..

声明元素

声明一个元素分为以下几部分:

<元素名> {
    (
        <属性名>: <表达式>,
        +<元素指令>: <表达式>,
    )*

    <子节点列表>
}

这是一个带有textselect属性的TextBox元素。

#![allow(unused)]
fn main() {
TextBox {
    +id: ("text_box", 12345),
    text: "this is a &str",
    select: true,
}
}

元素内指令和属性的不同之处在于它是以+开头的。目前,只有两个指令,styleid。其中,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”。

1

snake_case是一种命名规则,用于将多个单词连接成一个单词并用下划线分隔。在snake_case中,单词以小写字母表示,并使用下划线分隔,例如:my_variable_name。而在rust中,类型应该以CamelCase的命名方式出现。

动画

正在开发中...