Unity でも宣言的 UI 使いたくない??
1 年前に Unity で 状態管理をするフレームワークを作りました。
しかし、まだ辛いところがあったのです。
「宣言的に UI を書きたくない??」
宣言的 UI とは?
宣言的 UI は宣言型プログラミングを用いて構成された GUI、それを実現する手法である。GUI の生成・更新を変更前状態に基づいた更新命令によってコーディングするのではなく、あるべき状態を宣言してコーディングする。状態を分離することで UI の状態をより予測しやすいものにできる。テンプレートエンジンは静的テンプレートと動的変数の関係を宣言しているとみなせるため、更新された状態とテンプレートからテンプレートエンジンによって UI 生成をおこなって UI を更新する形は宣言的 UI といえる。そういった意味でも宣言的 UI 自体は古くから存在する GUI 実装手法の 1 つである。
簡単に言うと「この状態のときはこの UI にする」というのを設定することです。
HTML でいうと、「HPが10のときには下のようなHTMLにする」
<div><span>HP:</span><span>10</span></div>
見たいな感じですね
しかし、UI って動的な値を反映させてあげないといけないので愚直に宣言的 UI を実装しようとすると UI の構成要素を全部新しく作らないといけません。
これってかなり重たい処理で、Unity で言うと UI に反映させたい値(HP とか)を更新するたびに uGUI の構成要素を Instantiate
するということです(逆に使わなくなった UI は、Destory
するということ)
作ったもの
そこで今回作ったライブラリは、効率よくUIの変更を更新できるようになっています!
仕組み
参考にしたのは、React や Vue などの Web フロントエンドのフレームワークで使われている 仮想 DOMの概念です。
仮想 DOM
仮想 DOM の概念はシンプルなもので表示に使っている Object(Web なら DOM、Unity なら GameObject)を仮想的なもの(木構造になっているデータ構造)で表して、更新の際はその木構造の差分を計算して必要な変更だけをしてあげる。
というものです。
機能
この Veauty自体の機能は主に2つで
- 仮想 Object の Tree を作成
- 差分の計算
があります。
ここで気になった人もいると思いますが、必要な変更だけをしてあげるが入ってないんですね。
この部分は来る UIElement に向けて 無駄に抽象化をしていてこのライブラリを使って実装するようにしています。(詳細は次の章で)
使い方
先程も言ったとおりこのライブラリだけだと GameObject に反映出来ないので以下のライブラリを利用します。
今回はカウンターを作りながら使い方を見ていきます。
インストール方法
Unity Package Manager を利用しているのでプロジェクトルート以下にある Packages/manifest.json
に
{"dependencies":{..."com.uzimaru.veauty":"https://github.com/uzimaru0000/Veauty.git","com.uzimaru.veauty-gameobject":"https://github.com/uzimaru0000/Veauty-GameObject.git",...}}
と追記してエディタに行くとインストールされます.
ボイラープレート
若干のボイラープレート的な物を書かないといけないのでそれにコードを追加していく形で解説していきます。
// UIRoot.csusingUnityEngine;usingUI=UnityEngine;usingVeauty;usingVeauty.VTree;usingVeauty.GameObject;publicclassUIRoot:MonoBehaviour{privateVeautyObjectveauty;voidStart(){this.veauty=newVeautyObject(gameObject,Render,true);}voidRender()=>newNode("GameObject",IAttribute[]{},IVTree[]{});}
急にいろいろなクラスが出てきていますが順を追って説明していきます。
ここで作成された UIRoot
クラスは Canvas に Attach してください。
UI を作成
察しのいい人は分かると思うのですが、Render
メソッドに UI の定義を書いていきます。
GameObject の作成
早速、ただの GameObject は以下のように宣言します。
// UIRoot.csIVTreeRender()=>newNode("GameObject",IAttribute[]{},IVTree[]{});
Node
クラスが何も component がついていない GameObject を生成する要素です。
第1引数の文字列は、GameObject の名前を示していてここの文字列が違うと前回とは違う要素だと判断して再描画されます。
第2引数の IAttribute
の配列は、この GameObject の Component に何かしらの値を反映させるためのものです。(transform.position
の変更とか)
最後の引数の IVTree
の配列は、この GameObject の子要素の配列になります。
Component のついた GameObject の作成
このままでは何もすることが出来ないので GameObject に Component をつけていきます。HorizontalLayoutGroup
のついた GameObject を作成してみましょう。
// UIRoot.csIVTreeRender()=>newNode<UI.HorizontalLayoutGroup>("HorizontalLayoutGroup",newIAttribute[]{},newIVTree[]{});
Node
クラスの Generics に Attach したい Component の型をつけてあげるだけです。簡単ですね!
Button を作成する
カウンターの値を加算・減算するための Button を作成しましょう。
前のコードと同じように Button
クラスのついたNodeを作成しましょう!
// UIRoot.csIVTreeRender()=>newNode<UI.HorizontalLayoutGroup>("HorizontalLayoutGroup",newIAttribute[]{},newIVTree[]{newNode<UI.Button>("Button",IAttribute[]{},newIVTree[]{})});
これで一旦動かして見ましょう。
ヒエラルキー上で定義したような階層構造になっていることが分かると思います。
しかし、uGUI のButton
クラスは押すために Graphic
クラスをtargetGraphic
に設定しないといけないため現状では動きません。。。
そんな少し複雑になっている UI を作成するために使うのが Widget
クラスです。
// ButtonWidget.cspublicclassButtonWidget:Widget{privateIAttribute[]attrs;privateIVTree[]kids;publicButtonWidget(IAttribute[]attrs,IVTree[]kids){this.attrs=attrs;this.kids=kids;}publicoverrideGameObjectInit(GameObjectgo){varimage=go.AddComponent<UI.Image>();varbtn=go.GetComponent<UI.Button>();btn.targetGraphic=image;returngo;}publicoverrideIVTreeRender()=>newNode<UI.Button>("Button",this.attrs,this.kids);publicoverridevoidDestroy(GameObjectgo){}publicoverrideIVTree[]GetKids()=>this.kids;}
少し長いですが、こんな感じのコードです。
順を追って説明していきます。
Widget
今回のメインの Widget
クラスを継承します。このクラスは抽象クラスになっているので以下のメソッドをオーバーライドしなければいけません。
GameObject Init(GameObject go)
IVTree Render()
void Destory(GameObject go)
IVTree[] GetKids()
Init
メソッド
このメソッドは、実体化した GameObject の初期設定をするためのメソッドです。
今回でいうと、Image
クラスを Attach してButton
クラスの targetGraphic
に設定しています。(Button
クラスは Node
クラスの Generics で設定済み)
Render
メソッド
widget 内での UI の宣言です。
今回は、Node
クラスにButton
クラスをつけてコンストラクタで受け取った Attributes と子要素を渡しています。
Destory
メソッド
この Widget が削除されるときに実行されるメソッドです(実はまだ未実装)
GetKids
メソッド
子要素を返します。
早速ここで作成した、Button
を使ってボタンを作成してみましょう!
IVTreeRender()=>newNode<UI.HorizontalLayoutGroup>("HorizontalLayoutGroup",newIAttribute[]{},newIVTree[]{newButton(IAttribute[]{},newIVTree[]{})});
これでボタンが生成されたと思います!
テキストに文字を指定する
ボタンは出来ましたが、中に入る Text
が出来ていません。
とりあえず Text
を出す Widget を作成します。
usingVeauty.VTree;usingUnityEngine;usingVeauty;usingUI=UnityEngine.UI;publicclassText:Widget{privateIAttribute[]attrs;publicText(IAttribute[]attrs){this.attrs=attrs;}publicoverrideIVTree[]GetKids()=>newIVTree[0];publicoverrideGameObjectInit(GameObjectgo){vartextComponent=go.GetComponent<UI.Text>();textComponent.font=Resources.GetBuiltinResource<Font>("Arial.ttf");textComponent.alignment=TextAnchor.MiddleCenter;textComponent.color=Color.black;returngo;}publicoverrideIVTreeRender()=>newNode<UI.Text>("Text",attrs,GetKids());publicoverridevoidDestroy(GameObjectgo){}}
Widget の中身は Button と同じようなものなので省略します。
さて、ここで文字を指定するにはどうしたら良いでしょう?
普通の Unity だったら UI.Text
の text
に表示したい文字を入れます。では、Veauty だったら?
答えは Attribute を使います。
上でも少し説明したように Attribute
とは GameObject の Component に何かしらの値を反映させるためのものです。
なので今回は UI.Text
の text
に表示したい文字列を反映させる Attribute を作成しましょう。
ValueAttribute
Text だと Widget の方とかぶってしまうので Value
という名前にします。
// Value.csusingVeauty;usingUI=UnityEngine.UI;publicclassValue:IAttribute{privatestringvalue;publicValue(stringvalue){this.value=value;}publicstringGetKey()=>"Value";publicvoidApply(GameObjectobj){vartextComponent=obj.GetComponent<UI.Text>();if(textComponent){textComponent.text=this.value;}}publicboolEquals(IAttributeattr){if(attrisValueother){returnthis.value==other.value;}returnfalse;}}
IAttribute
インターフェースで実装するメソッドは 3 つです。
string GetKey()
この Attribute を識別するためのものです。
void Apply(GameObject obj)
渡ってきた Object に対してこの Attribute がしたい操作を反映させます。
bool Equals(IAttribute attr)
渡ってきた IAttribute
を見てこの Attribute と等しいかを判定します。
実際に使って見ましょう。
// UIRoot.csIVTreeRender()=>newNode<UI.HorizontalLayoutGroup>("HorizontalLayoutGroup",newIAttribute[]{},newIVTree[]{newButton(IAttribute[]{},newIVTree[]{newText(newIAttribute[]{newValue("↑")})}),newText(newIAttribute[]{newValue("0")}),newButton(IAttribute[]{},newIVTree[]{newText(newIAttribute[]{newValue("↓")})}),});
だんだん形が見えて来ましたね。
OnClick を実装する
Button
に対する OnClick もAttribute
として実装します。
コードは以下のようになります。
// OnClick.csusingUnityEngine;usingUI=UnityEngine.UI;usingEvents=UnityEngine.Events;usingVeauty;publicclassOnClick:IAttribute{privateEvents.UnityActionaction;publicOnClick(Events.UnityActionaction){this.action=action;}publicstringGetKey()=>"OnClick";publicvoidApply(GameObjectobj){varbutton=obj.GetComponent<UI.Button>();if(button){button.onClick.RemoveAllListeners();button.onClick.AddListener(this.action);}}publicboolEquals(IAttributeattr){if(attrisOnClickother){returnthis.action==other.action;}returnfalse;}}
これを使うとこんな感じですね
// UIRoot.csIVTreeRender()=>newNode<UI.HorizontalLayoutGroup>("HorizontalLayoutGroup",newIAttribute[]{},newIVTree[]{newButton(newIAttribute[]{newOnClick(()=>Debug.Log("↑"))},newIVTree[]{newText(newIAttribute[]{newValue("↑")})}),newText(newIAttribute[]{newValue("0")}),newButton(newIAttribute[]{newOnClick(()=>Debug.Log("↓"))},newIVTree[]{newText(newIAttribute[]{newValue("↓")})}),});
これでボタンを押すと console に Log が出ると思います。
State を更新する
最後に State を更新してみましょう!
Veauty では State の更新をするために VeautyObject
の SetState
メソッドを使って State 更新用の関数を生成します。
コードで見るとこんな感じです。今回は、counter
という int
型の値を State とします。
// UIRoot.csusingUnityEngine;usingUI=UnityEngine.UI;usingVeauty;usingVeauty.VTree;usingVeauty.GameObject;publicclassSample:MonoBehaviour{privateVeautyObjectveauty;privateintcounter=0;privateSystem.Action<int>setCounter;voidStart(){this.veauty=newVeautyObject(gameObject,Render,true);this.setCounter=this.veauty.SetState<int>(n=>this.counter=n);}IVTreeRender()=>newNode<UI.HorizontalLayoutGroup>("HorizontalLayoutGroup",newIAttribute[]{},newIVTree[]{newButtonWidget(newIAttribute[]{newOnClick(()=>this.setCounter(this.counter+1))},newIVTree[]{newText(newIAttribute[]{newValue("↑")})}),newNode("Display",newIAttribute[0],newIVTree[]{newText(newIAttribute[]{newValue($"{this.counter}")}),}),newButtonWidget(newIAttribute[]{newOnClick(()=>this.setCounter(this.counter-1))},newIVTree[]{newText(newIAttribute[]{newValue("↓")})}),});}
ここで State を OnClick 内で直接更新しないで this.setCounter
を経由して居ることを確認してください。
これは、VeautyObject
に State が変わったこと(再描画をしてほしいこと)を通知するためにこのような更新の仕方をしています。
普通にcounter
を更新をしてしまうと State が変わったことを検知できないため再描画がされません。。。
肝心の this.setCounter
ですが、Start メソッドで初期化しています。VeautyObject
のSetState
メソッドに更新するための操作を渡してあげると更新のための関数が生成されます。
これを利用して State
を更新すると再描画がされるといった仕組みです。
最後に
これが Veauty
のおおよその使い方になります!
しかし、まだまだ不備があったりと完全なものではないので興味のある人は是非コントリビュートをしていただけるとありがたいです
(ちなみにここで Widget と Attribute の作り方を丁寧に説明したのは、uGUI 用の Widget や Attribute をまとめたライブラリの Veauty-uGUIに協力してほしいからです...!)
また、Veautyでこんなの作ったよ!というのもの常に受け付けているのでぜひ触って見てください!!
それでは!!