UIWidgets源码系列:Hello World

什么是UIWidgets

UIWidgets是Unity上的一个UI解决方案。UIWidgets是将目前流行的跨平台移动开发框架flutter在Unity上的移植。UIWidgets有如下优势:

  1. 站在flutter这个巨人的肩膀上,UIWidgets使得Unity游戏开发者能够在Unity游戏中嵌入复杂性和移动APP比肩的游戏UI,甚至于抛开游戏,制作完全由UI界面组成的应用。
  2. 站在Unity平台上,UIWidgets很容易实现在APP中嵌入复杂的3D场景,其他移动开发框架做到同样的效果难度大得多。
  3. 借助于Unity的跨平台特性,UIWidgets不仅跨Android和iOS,还跨Windows、Linux、MacOS、WebGL等二十多个平台。
  4. 所有flutter的使用文档几乎都可以直接应用于UIWidgets,学习UIWidgets的过程直接受益于flutter丰富的文档和教程。

UIWidgets的使用也很简单,不依赖任何第三方库,支持2018.3及以上的Unity版本。将源码拷贝到工程目录中就可以开始使用。此外UIWidgets已在Package Manager(国内镜像)和Asset Store上发布。不过推荐在github上clone源码,以获取到最新的代码。

本文假设您已经了解Unity的基本操作(新建工程、在场景中创建各种GameObject等)。如果没有,这些技能学习起来也非常简单。您可以访问Unity Learn查看官方的免费教程。

使用UIWidgets做一个简单的APP

  1. 打开2018.3或更高版本的Unity,新建工程

  2. 获取UIWidgets(三种途径任选一种):

    1. 使用git将 https://github.com/UnityTech/UIWidgets.git clone到工程目录中,放到Packages文件夹下(或Assets目录中也没问题)
    2. 打开Window > Packages Manager,点击搜索框左边的"Advanced",确保"Show preview packages"为被选中状态。随后,在列表中找到并点击UIWidgets,然后点击右下角的"Install"
    3. 打开Asset Store,搜索UIWidgets,下载并导入工程中。具体步骤和在Asset Store下载其他资源类似,在此不细讲。
  3. 在工程目录中的Assets目录下新建脚本,命名为UIWidgetsExample.cs。将以下内容粘贴进去:

     using System.Collections.Generic;
     using Unity.UIWidgets.animation
     using Unity.UIWidgets.engine;
     using Unity.UIWidgets.foundation;
     using Unity.UIWidgets.material;
     using Unity.UIWidgets.painting;
     using Unity.UIWidgets.widgets;
    
     public class UIWidgetsExample : UIWidgetsPanel {
       protected override Widget createWidget() {
         return new ExampleApp();
       }
     }
    
     class ExampleApp : StatefulWidget {
       public ExampleApp(Key key = null) : base(key) {
       }
    
       public override State createState() {
         return new ExampleState();
       }
     }
    
     class ExampleState : State<ExampleApp> {
       int counter = 0;
    
       public override Widget build(BuildContext context) {
         return new Column(
           children: new List<Widget> {
             new Text("Counter: " + this.counter),
             new GestureDetector(
               onTap: () => {
                 this.setState(() => {
                   this.counter++;
                 });
               },
               child: new Container(
                 padding: EdgeInsets.all(20),
                 color: Colors.blue,
                 child: new Text("Click Me")
               )
             )
           }
         );
       }
     }
  4. 在场景中新建一个Canvas:在Hierachy窗口中右键 -> UI -> Canvas。

  5. 点击Scene窗口左上角的2D按钮打开2D视角。

  6. 将UIWidgetsExample脚本添加到Canvas上:

    1. 从Project面板中将脚本拖到场景中的Canvas上;或者
    2. 点击Canvas,在Inspector窗口中点击“Add Component”,在搜索框中输入“UI Widgets Example”,找到后点击

这个简单的UIWidgets应用效果如下。

UIWidgets示例代码效果

点击“Click Me”按钮,可以看到Counter后面的数字增加。

如果你觉得上述例子过于简单,可以打开场景“Samples/UIWidgetSample/UIWidgetsGallery/gallery.scene”看一下UIWidgets做出的界面。下面是一些截图。

关于目前已有的成熟的UIWidgets做的产品,可以参考凉鞋老师整理的awesome-uiwidgets

示例程序简析

这一节我们简单讲解一下上文中的示例程序。我打算从最简单的UIWidgets程序开始,一步步扩展,最终得到这个示例程序,争取解释清楚一个基本的UIWidgets程序的框架。

最简单的UIWidgets程序长什么样儿?

我能想到的最简单的UIWidgets程序如下:

using Unity.UIWidgets.engine;

public class UIWidgetsExample : UIWidgetsPanel {
}

将这个脚本拖到Canvas上,一切都能正常运行,只不过场景空空如也。

使用组件

为了在Canvas上显示文字,我们为UIWidgetsPanel提供一个createWidget()函数,让它返回一个Text组件。

using Unity.UIWidgets.engine;
using Unity.UIWidgets.widgets;

public class UIWidgetsExample : UIWidgetsPanel {
  protected override Widget createWidget() {
    return new Text("Click Me");
  }
}

注意,原先我们只引用了engine模块,现在增加了widgets模块。

现在,场景的左上角出现了“Click Me”。

这里我们用到了组件(Widget)的概念。组件是搭建UIWidgets图形界面的基本单位。从UIWidgets的名字就可以看出组件在其中的地位。实际上,在UIWidgets应用中,能看到的一切都是组件。UIWidgets中有两类组件:基本组件组合组件

基本组件是那些简单得没法再拆分的组件,比如Text组件。

组合组件组合组件一般会有一个或多个子组件,负责将多个组件组合在一起,比如RowColumn组件,负责将子组件排成一行或是一列。或者给子组件添加效果,如Center组件,让子组件居中,或Opacity组件,调整子组件的透明度,等等。

在使用UIWidgets绘制界面时,我们需要定义自己的组件。一般情况下,我们只需要定义组合组件,把UIWidgets的内置组件组装一下,就能满足大多数需求了。不过,如果要做出不寻常的效果,你也可以自定义一些基本组件,对其排版和绘制的过程进行精细的控制。

使用Container修饰基本组件

接着,我们给文字加上边框和背景色。为此,我们将Text组件包装在Container组件中。如果你熟悉HTML,你可以理解为UIWidgets里的Container就和网页中的<dev>标签是一样的。

using Unity.UIWidgets.engine;
using Unity.UIWidgets.widgets;

public class UIWidgetsExample : UIWidgetsPanel {
  protected override Widget createWidget() {
    return new Container(
      child: new Text("Click Me")
    );
  }
}

此时界面没有任何变化。一个除了child没有任何参数设置的Container,表现得和其child一模一样,是一个完全没有存在感的Container

现在我们给Container加一些配置。

using Unity.UIWidgets.engine;
using Unity.UIWidgets.widgets;
using Unity.UIWidgets.painting;
using Unity.UIWidgets.material;

public class UIWidgetsExample : UIWidgetsPanel {
  protected override Widget createWidget() {
    return new Container(
      padding: EdgeInsets.all(20),
      color: Colors.blue,
      child: new Text("Click Me")
    );
  }
}

我们设置了Container四边内边距都为20,颜色为蓝色。注意到,因为用了EdgeInsetsColors类,我们引用了paintingmaterial模块。

我们发现整个场景都被蓝色充满了。这是因为Container排版(Layout)方式导致的。如果Container在某个方向上没有设置大小,它就会自动扩张到其父组件的大小。为了方便描述,称这样大小由父组件确定的排版方式为扩张型,也就是能有多大就有多大。现在, 因为这个Container是整个APP的最顶层的组件,它没有父组件,排版时“父组件大小”就是显示设备的大小。

我们希望的效果是Container的大小由内部的文字决定。解决这个问题的方法是利用Container的另一个排版规则:如果外面的空间是不确定大小的(或者说父组件会等待子组件确定大小,以确定给它多少空间),那么Container的排版方式就转为收缩型,即能有多小有多小,只要能容纳下子组件(并满足内边距等设置)就好。

因为接下来需要用到Column组件,而Column组件正是根据子组件的大小给其分配空间,因此这个问题会自然得到解决。

使用Column组件

我们先在Column中只放一个子组件,也就是我们的Container组件,观察效果。

using System.Collections.Generic;
using Unity.UIWidgets.engine;
using Unity.UIWidgets.widgets;
using Unity.UIWidgets.painting;
using Unity.UIWidgets.material;

public class UIWidgetsExample : UIWidgetsPanel {
  protected override Widget createWidget() {
    return new Column(
      children: new List<Widget>
      {
        new Container(
          padding: EdgeInsets.all(20),
          color: Colors.blue,
          child: new Text("Click Me")
        )
      }
    );
  }
}

可以看到,Container组件缩小到Text周围,并出现在顶部,左右居中。这是因为Column组件默认是扩张型的,于是占据整个屏幕。它从顶端开始排列其子组件。而在左右方向上的对齐方式默认为居中。

现在我们在Container上方增加描述计数器的文本。

using System.Collections.Generic;
using Unity.UIWidgets.engine;
using Unity.UIWidgets.widgets;
using Unity.UIWidgets.painting;
using Unity.UIWidgets.material;

public class UIWidgetsExample : UIWidgetsPanel {
  protected override Widget createWidget() {
    return new Column(
      children: new List<Widget>
      {
        new Text("Counter: 0"),
        new Container(
          padding: EdgeInsets.all(20),
          color: Colors.blue,
          child: new Text("Click Me")
        )
      }
    );
  }
}

自定义组件:无状态组件

现在我们的APP已经有了最终目标的样子,但并不能响应点击事件,也不能让计数器的值增加。为了让计数器能随点击而增加,我们首先将返回值封装为自定义的组件,命名为ExampleApp()。简单起见,先介绍无状态组件(StatelessWidget)

using System.Collections.Generic;
using Unity.UIWidgets.engine;
using Unity.UIWidgets.widgets;
using Unity.UIWidgets.painting;
using Unity.UIWidgets.material;

public class UIWidgetsExample : UIWidgetsPanel {
  protected override Widget createWidget() {
    return new ExampleApp();
  }
}

public class ExampleApp : StatelessWidget {
  public override Widget build(BuildContext context) {
    return new Column(
      children: new List<Widget>
      {
        new Text("Counter: 0"),
        new Container(
          padding: EdgeInsets.all(20),
          color: Colors.blue,
          child: new Text("Click Me")
        )
      }
    );
  }
}

可以看到,我们将原来createWidget()函数的返回值,直接搬到了ExampleAppbuild()函数中。

无状态组件StatelessWidget没有任何内部状态。出现StatelessWidget的地方,等价于将其build()函数的返回值替代掉它自己。因此,这一步没有效果上的改变,只是将我们需要的组件封装为一个自定义的组件。这和将几行代码封装为一个函数是类似的。

注意到build()函数需要一个BuildContext参数。这个参数的意义现在可以先忽略。

自定义组件:状态组件

我们想要计数器的值随着点击按钮而改变。这就需要组件能够储存内部状态。StatelessWidget不能完成这样的任务,因此我们需要状态组件(StatefulWidget)

using System.Collections.Generic;
using Unity.UIWidgets.engine;
using Unity.UIWidgets.widgets;
using Unity.UIWidgets.painting;
using Unity.UIWidgets.material;

public class UIWidgetsExample : UIWidgetsPanel {
  protected override Widget createWidget() {
    return new ExampleApp();
  }
}

public class ExampleApp : StatefulWidget {
  public override State createState() {
    return new ExampleAppState();
  }
}

class ExampleAppState : State<ExampleApp> {
  public override Widget build(BuildContext context) {
    return new Column(
      children: new List<Widget>
      {
        new Text("Counter: 0"),
        new Container(
          padding: EdgeInsets.all(20),
          color: Colors.blue,
          child: new Text("Click Me")
        )
      }
    );
  }
}

可以看到,StatefulWidget需要定义一个createState()函数,返回其对应的State类的对象,其build()函数也转移到了对应的State类中。这个State类就代表了组件的内部状态。

因为我们要记录按钮的点击次数,我们在State类中定义一个counter变量。

using System.Collections.Generic;
using Unity.UIWidgets.engine;
using Unity.UIWidgets.widgets;
using Unity.UIWidgets.painting;
using Unity.UIWidgets.material;

public class UIWidgetsExample : UIWidgetsPanel {
  protected override Widget createWidget() {
    return new ExampleApp();
  }
}

public class ExampleApp : StatefulWidget {
  public override State createState() {
    return new ExampleAppState();
  }
}

class ExampleAppState : State<ExampleApp> {
  int counter = 0;
  public override Widget build(BuildContext context) {
    return new Column(
      children: new List<Widget>
      {
        new Text("Counter: " + counter),
        new Container(
          padding: EdgeInsets.all(20),
          color: Colors.blue,
          child: new Text("Click Me")
        )
      }
    );
  }
}

响应点击事件

为了让Container能够检测到点击事件,给它包装一层GestureDetector。后者可以识别许多鼠标/触屏手势。

using System.Collections.Generic;
using Unity.UIWidgets.engine;
using Unity.UIWidgets.widgets;
using Unity.UIWidgets.painting;
using Unity.UIWidgets.material;
using UnityEngine;

public class UIWidgetsExample : UIWidgetsPanel {
  protected override Widget createWidget() {
    return new ExampleApp();
  }
}

public class ExampleApp : StatefulWidget {
  public override State createState() {
    return new ExampleAppState();
  }
}

class ExampleAppState : State<ExampleApp> {
  int counter = 0;
  public override Widget build(BuildContext context) {
    return new Column(
      children: new List<Widget>
      {
        new Text("Counter: " + counter),
        new GestureDetector(
          child: new Container(
            padding: EdgeInsets.all(20),
            color: Colors.blue,
            child: new Text("Click Me")
          ),
          onTap: () => {
            Debug.Log("Clicked");
          }
        )
      }
    );
  }
}

我们将一个函数传递给onTap属性,这个函数会在点击事件发生时执行。现在,单击这个Container,Unity的Console窗口中会打印一行"Clicked"。

更新计数器

最后,我们修改传递给onTap参数的函数体,让其更新计数器的值。

using System.Collections.Generic;
using Unity.UIWidgets.engine;
using Unity.UIWidgets.widgets;
using Unity.UIWidgets.painting;
using Unity.UIWidgets.material;

public class UIWidgetsExample : UIWidgetsPanel {
  protected override Widget createWidget() {
    return new ExampleApp();
  }
}

public class ExampleApp : StatefulWidget {
  public override State createState() {
    return new ExampleAppState();
  }
}

class ExampleAppState : State<ExampleApp> {
  int counter = 0;
  public override Widget build(BuildContext context) {
    return new Column(
      children: new List<Widget>
      {
        new Text("Counter: " + counter),
        new GestureDetector(
          child: new Container(
            padding: EdgeInsets.all(20),
            color: Colors.blue,
            child: new Text("Click Me")
          ),
          onTap: () => {
            counter++;
          }
        )
      }
    );
  }
}

然而,现在我们点击Container,并没有什么作用。原因是我们并没有通知渲染系统这个组件的状态已经更新。通知的方法是将更新状态的操作封装在函数中,传递给setState()函数。

using System.Collections.Generic;
using Unity.UIWidgets.engine;
using Unity.UIWidgets.widgets;
using Unity.UIWidgets.painting;
using Unity.UIWidgets.material;

public class UIWidgetsExample : UIWidgetsPanel {
  protected override Widget createWidget() {
    return new ExampleApp();
  }
}

public class ExampleApp : StatefulWidget {
  public override State createState() {
    return new ExampleAppState();
  }
}

class ExampleAppState : State<ExampleApp> {
  int counter = 0;
  public override Widget build(BuildContext context) {
    return new Column(
      children: new List<Widget>
      {
        new Text("Counter: " + counter),
        new GestureDetector(
          child: new Container(
            padding: EdgeInsets.all(20),
            color: Colors.blue,
            child: new Text("Click Me")
          ),
          onTap: () => {
            setState(() => counter++);
          }
        )
      }
    );
  }
}

现在,我们得到了最开始时看到的示例。

总结

本文中,我们介绍了Unity上的UI解决方案UIWidgets,以及怎样用UIWidgets制作一个简单的APP。本系列是UIWidgets源码系列,后续文章中会解析UIWidgets源码,讲解UIWidgets的原理。欢迎大家继续关注。