Unity:嘗試使用Zenject、Unity的Localization在地化插件與UIToolkit在一起

 


 

前陣子接觸到Unity的新的UI工具UIToolkit,讓我想到以前碰到網頁排版和樣式要寫CSS的日子。很好奇目前重製版專案裡的UI是否也能順利轉換到UIToolkit上,由於我UI在功能上有用到Zenject所以想趁著這陣子有空來測試看看,能不能順利轉換。

之所以寫這篇文章是因為網路上查到有用 UIToolkit和Zenject在一起的人並不多,所以想拋磚引玉看看有沒有人也有類似經驗可分享。

測試對象:一個會使用到Zenject和Localization功能的按鈕

測試的項目很簡單,挑一個比較有代表性的元素做測試。

我選擇的是一個帶有文字敘述和圖片的按鈕,這個按鈕其實是展示畫作圖片的按鈕。UGUI版本中是一個Prefab,上面有個元件可以指定這個按鈕的圖片和文字敘述,文字敘述會由Localization這個插件負責載入在地化版本的文本,而這個按鈕的功能很簡單,按下去後會使用Zenject的Signal發出訊息,會滿版顯示這個按鈕的圖片和文字敘述,以下的影片為UGUI版本的展示:

由於我選擇的按鈕文字敘述和圖片是綁在一起的, 所以我就用ScriptableObject包起來,裡面裝有LocalizedString和Sprite。其中有個事件,用來通知在地化文本的語言變更,UI的部分接收到通知後再取得最新的在地化文本。

怎麼寫客製化的VisualElement

針對怎麼寫一個客製化的VisualElement在官方的文件有簡單的說明,可以先看此:

我這邊客製化的VisualElement稱作ImageBoxElement,以下的程式也會用這個名字示範。

客製化的VisualElement裡面的內容可以透過程式添加,類似這樣:

  1. ImageBoxElement.Add(new Label("NewLabel"));

或者是透過UIBuilder做出一個範本(類型是VisualTreeAsset),並透過在程式中載入的方式添加,有點像是在UGUI排好UI元素然後做成Prefab的概念。記住裡面會用程式控制的元素記得命名。

如下圖就是我製作的範本,稱作ImageBox:



載入並動態生成的方法,我是用Addressables載入:

  1. public class ImageBoxElement : VisualElement
  2. {
  3. //_paintData是儲存畫作資料的ScriptableObject,怎麼給就看個人做法
  4. private PaintingData _paintData;
  5. private Label _header = default;
  6. private VisualElement _image = default;
  7. private Label _name = default;
  8. }
  9.     public ImageBoxElement()
  10. {
  11. //從Addressables載入製作好的UI範本
  12. VisualTreeAsset tree = Addressables.LoadAsset<VisualTreeAsset>("ImageBox").WaitForCompletion();
  13. //從範本裡面複製一份副本到客製化的VisualElement裡
  14. tree.CloneTree(this);
  15. //把底下有關文本和圖片的VisualElement存起來,提供之後修改內容使用
  16. _header = this.Q<Label>("Header");
  17. _image = this.Q<VisualElement>("Image");
  18. _name = this.Q<Label>("Name");
  19.     }
  20. }

如此一來客製化的VisualElement裡面就會有在UIBuilder做的ImageBox裡所含的UI。而使用上跟一般內建的VisualElement差不多,可以用程式添加或是在UIBuilder中拖曳添加。

客製化的VisualElement中處理文字在地化

UIToolkit據官方說法,目前並沒有官方支援Localization插件的功能,所以要使用Localization來處理在地化就需要自己寫。作法大致上是自己寫一個類似Label的VisualElement(或是TextElement),透過LocalizedString自行訂閱LocalizedString.StringChanged來更新文字的內容,這樣就能透過Localization插件依照語言在UIToolkit上顯示在地化的文本。雖然我這邊只提到文字,其他如圖片的在地化應該也差不多。有人有嘗試實作可參考此文章。 
 
由於UIToolkit目前的限制,無法直接在UIBuilder裡面選取指定的LocalizedString,所以只能填LocalizedString的Key來查找,像是ScriptableObject的物件參考也無法。

而訂閱LocalizedString.StringChanged來變更Label的文本內容會像是:
  1. LocalizedString.StringChanged += (str) => VisualElement.Q<Label>("Header").text = str;

由於我的在地化文本有使用到SmartString,所以我並沒有直接用這種方法,LocalizedString.StringChanged只是用來通知文本變更,然後透過LocalizedString.GetLocalizedString取得處理好的文本,這些部分被我封裝在ScriptableObject裡。

有關處理文字在地化的部分差不多了,再來就是怎麼讓這些客製化VisualElement使用Zenject的Signal互相溝通呢?

 

客製化的VisualElement與Zenject Signal

為了測試使用Zenject Signal的功能,我在UIBuilder做了一個很類似UGUI版本的介面,裡面含有四個ImageBoxElement,其中一個平時隱藏,並且名稱為Target,只有點選其他三個才會顯示這個隱藏的,並顯示全螢幕的圖片與文字敘述。功能上就跟上面影片中展示的很像。
為了讓這幾個ImageBoxElement可以互相溝通,ImageBoxElement要可以發出Signal和接收Signal。跟平常一樣我們要在ImageBoxElement注入SignalBus以發送Signal。在ImageBoxElement裡簡單加個欄位注入:
  1. [Inject] readonly SignalBus _signalBus;

並且在Installer裡將ImageBoxElement抓出來,並做Binding,才能完成BindSignal和注入SignalBus。

為了將我們做好的UI顯示在畫面上和抓取ImageBoxElement,就需要在場上放一個GameObject並掛上UIDocument元件,將我們做好的UIVisualTreeAsset放上去,這樣就能在畫面上看到UI,並且可以透過程式控制UI的元素,並抓取底下的VisualElement。

從UIDocument取得最上層的rootVisualElement後查找所有ImageBoxElement,先儲存起來,並用匿名方法Binding。然後針對每一個ImageBoxElement做注入,否則會Zenject不會注入,因為ImageBoxElement生成不是由Zenject處理,而且生成的時間很早。

 更新:Zenject 9.3.1版已經有內建處理了,無須手動操作。

有關注入,手動對每個物件要求注入是我目前是出來唯一可以做到的方法,不知道有沒有其他更好的方法可以做到。🤔

  1. IEnumerable<ImageBoxElement> elements = UIDocument.rootVisualElement.Query<ImageBoxElement>().ToList().OfType<ImageBoxElement>();
  2. Container.Bind<ImageBoxElement>().FromMethodMultiple((ctx) => elements);
  3. foreach (ImageBoxElement element in elements)
  4. {
  5.   Container.QueueForInject(element);
  6. }

完成注入後,剩下的就相對簡單,只需要處理訊息發送和接收。與UGUI時使用Zenject的一樣,首先是在執行完CloneTree後註冊Callback以在滑鼠按下時發送Signal,Signal裡面會裝有存放畫作資料的ScriptableObject。

  1. //傳送畫作資料的Signal
  2. public class TestEvent { public PaintingData Data; }
  3.  
  4. //在ImageBoxElement建構式中CloneTree之後
  5. this.Q<Button>("Box").RegisterCallback<ClickEvent>((click) => _signalBus.Fire(new TestEvent() { Data = _paintData }));
然後處理接收訊息的部分
  1. //在Installer中
  2. Container.DeclareSignal<TestEvent>();
  3. Container.BindSignal<TestEvent>().ToMethod<ImageBoxElement>(x => x.ReactWhenOnClick).FromResolveAll();
  4.  
  5. //在ImageBoxElement中,這邊只是簡單示範開關VisualElement和更換內容而已
  6. public void ReactWhenOnClick(TestEvent t)
  7. {
  8. //只有名稱叫做Target的ImageBoxElement才需要反應
  9. if (this.name == "Target")
  10. {
  11. //開關顯示VisualElement
  12. if (this.style.display == DisplayStyle.Flex)
  13. this.style.display = DisplayStyle.None;
  14. else
  15. this.style.display = DisplayStyle.Flex;
  16. _header.text = t.Data.GetHeader();
  17. _image.style.backgroundImage = new StyleBackground(t.Data.GetImage());
  18. _name.text = t.Data.GetSubtitile();
  19. }
  20. }
這邊寫完後大致上功能就完成了,剩下的功能就差不多完成了,進入PlayMode測試就是最開頭影片的樣子。

留言

這個網誌中的熱門文章

Unity:製作可相交的視差遮蔽貼圖 Shader 續 (Parallax Occlusion Mapping Shader with Pixel Depth Offset)

Unity:嘗試製作可相交的視差遮蔽貼圖 Shader (Trying to Create an Parallax Occlusion Mapping Shader with Pixel Depth Offset)

Blender:Geometry Node 應用於營造法式生成大木作構件初探-梭柱篇