こんにちは、クラスターでUnity Engineerをやっている獏星(ばくすたー)です!
突然ですが、この記事を読んでいるという事はあなたは本日クラスターに入社したUnity Engineerのはずです。
もし違う場合でも、「そうか、自分は本日クラスターに入社したUnity Engineerだったのか…」と受け入れて読んで下さい。ただし入社ツイートは無しで。
そして本記事を読んでいるあなたに朗報です。
つい先日、クラスターに入社したUnity Engineer向けの社内用オンボーディング資料として「本日クラスターに入社したUnity Engineerが読む記事」が作成されました!そのまんまのタイトルですね。
クラスターではエンジニアのオンボーディングフロー改善に日々取り組んでおり、その一環として資料整備が進みました。
そこで今回は本日クラスターに入社したUnity Engineerの気持ちになった方に向けて、同資料を抜粋で紹介していきます。実務で書くものに近いコードを掲載していますが、説明はごっそりカットしてしまうため、なんとなく「こういうコード書いてるんだな~」程度にご覧ください。
この記事で扱う内容:
- clusterのクライアントはどこがUnityで作られているか
- Unityプロジェクトのリソース配置はどんな感じなのか
- コードの階層分けはどうなっているのか
- 実際のコードはどんな感じなのか
以下、抜粋しつつの紹介です。
はじめに
クラスターへようこそ!
clusterはメタバースサービスであるとともに、Unityの目線では「VR、PC、モバイル向けのマルチプラットフォームUnityアプリ」という側面を持ち、内製C#コードの規模感は20~30万行程度です。
Unityアプリとしてのclusterは一定の設計ポリシー、リソース配置ルール、コード規約を備えており、読み込んでいけば理解しやすいです。
しかし、あなたは本日クラスターに入社したUnity Engineerなので、巨大なUnityプロジェクトを丸ごと渡されても手の付け所に困っているはずです。
そこで今回はオンボーディングとして、Unityクライアントとして見たclusterの設計を概略から詳細へ紹介していきます。
このオンボーディングではUnityやC#の基礎知識のほか、UniRxとZenjectについて何かしらの予備知識があることを想定しています。とはいえ、「この書き方は初めて見た!」という事はしばしばあるはずなので、耳慣れない単語は自分で調べるか、slackや口頭でも聞いてみてください。
Unityプロジェクトの周辺事情
Unity Editorでプロジェクトを開く前に、プロジェクトの周辺事情をおさえます。Unityのプロジェクトに何が含まれ、何は含まれないかを確認してプロジェクトの輪郭を捉えましょう。
資料では3つの観点からプロジェクトの周辺事項を把握しています。
- ビルドの構成
- APIの種類: cluster特有の通信の話など
- Unityプロジェクトと関連が深い外部プロジェクト
ここではビルド構成の部分を特に詳しく紹介します。
clusterのUnityプロジェクトは通常の実行ファイルビルドとUaaL(Unity as a Library)の2通りでビルドされます。
モバイル(iOS / Android)ではUaaLビルドがネイティブアプリに乗り、アプリ全体としてはネイティブアプリ扱いでGoogle PlayやApp Storeにリリースされます。PCやQuest向けのビルドではUnityから実行ファイルを直接ビルドします。こちらは馴染み深いですね。
モバイルアプリではバーチャル空間に入る手前の段階、すなわち「アウトルーム(以下、outroom)」と呼ばれる機能区分がネイティブアプリで実装されています。アプリの起動からログイン、ワールドやイベントの検索、フレンドとのメッセージなどがoutroom機能として利用できます。
例えば、Android版clusterでログインした直後の画面はネイティブのAndroidアプリです。
あなたは本日クラスターに入社したUnity Engineerなので、もしかするとUnityでのUI実装で苦労した経験があり、「このUIをUnityで作るの…!?」と思っていたかもしれません。そうだったなら一安心ですね。
いっぽう、実行ファイルビルドではoutroomのうち最小限の機能であるログイン機能を備えています。
こうしたoutroomと対照的に、「インルーム(inroom)」すなわちバーチャル空間内のものは全てUnityで実装されています。
この区分けに基づくと、Unityプロジェクトの大部分ではinroom機能を実装しています。inroom機能の種類はとても多いので、初日はoutroom/inroomの区分が分かればOKです。
残りのトピックであるserver/client通信の概略や、メインプロジェクトと関連の深いプロジェクトについては紹介すると長くなるため、図をチラッと見せるだけに留めます。
Unityプロジェクトの内側へ / フォルダ分割から
今度はプロジェクトの中身を確認していきます。まずは大きなフォルダ分類から把握しておきましょう。
UniRxなどのOSSのほか、有料アセットのFinalIKについても名前を知っているかもしれません。
StaticResourcesフォルダにはコード以外のリソースでPrefabやUI画像などがいろいろ入っていますが、この中でも初日に理解するとよいのはシーン遷移です。
シーンの流れは全プラットフォーム共通で、「起動→ログイン確認→入室準備→入室済み」と進んでいきます。ただし、環境によってロードするシーンが異なるのがポイントです。
Unity Editorからの実行についても、起点となるClusterPlayerBootstrapシーンを実行すればビルド設定に応じて各プラットフォームのアプリ挙動を追っていけます。
プロジェクト内のコード階層
今度はコードの確認です。
先ほどの図をコード部分について拡大すると、参照関係がこのように階層化されています。
あなたは本日クラスターに入社したUnity Engineerなので、ドメイン駆動開発とかクリーンアーキテクチャに造詣があるかもしれません。その場合はこちらの階層分けでしっくり来る部分もあるでしょう。
ちなみに、clusterはコード全域でasmdefを定義しており、上記の階層分けに準ずる(もう少し細かい)asmdefがあります。そのため、間違った場所にコードを書くとアセンブリ参照エラーとかasmdefの循環参照という形で現れ、問題に気づきやすいです。
以下は下から順に追った、レイヤーごとの簡単な紹介です。
(※資料ではアンチパターンや細かい具体例にも触れています)
- ドメイン層(Domain): 最も基本的なレイヤーで、データ定義やインターフェース定義を持つ。IDをValueObject化した値など。
- インフラストラクチャ層(Infrastructure、以下インフラ層): データの永続化やAPI呼び出しの実装
- ユースケース層(UseCase): ビジネスロジックを書く層。アバターの座標とカメラ姿勢に基づいた、近くの(高画質で描画すべき)アバターの判別処理など。
- プレゼンテーション層(Presentation): MonoBehaviourと縁が深い処理でありつつ、GUI的な実装ではないようなもの。カメラの制御など。
- UI層: GUIのレイヤー。clusterではMVP(Model/View/Presenter)パターンで実装される。
- インストーラ層(MonoInstaller): Zenjectの仕組みに乗って依存注入を行う。レイヤー名はMonoInstallerだが、InstallerやScriptableObjectInstallerも含む
※図中のClusterUIという層はいわゆるレガシーコードです。実際の資料中ではどういう経緯でレガシーコードがあり、どう減らしているのかにも言及しています。ここでは単語を一つ紹介するに留めておくと、クラスターではレガシーコード削除のような活動は「粉砕」と呼ばれるグッドプラクティスの一環に位置づけられています。
階層以外の切り口からもコードの特徴を概観しましょう。
資料中では3つの観点を挙げています。
- MonoBehaviourはどこにあるか
- HotなクラスとColdなクラス (UniRx的な観点から)
- Zenjectはどのくらいガッツリ使っているのか
ここでは最初の項目、つまりMonoBehaivourについてピックアップします。
プロジェクト中でのMonoBehaviourの使われ方は限定的です。
- UI層のViewクラス
- プレゼンテーション層の一部で、オブジェクト制御が必要なもの
- MonoInstaller
他はただのC#クラスだったり、ScriptableObjectの派生クラスが少量ある、という感じです。
あなたは本日クラスターに入社したUnity Engineerなので、もしかすると学生時代や前職で何でもMonoBehaviourなコードベースを書いて「うまく言えないが書き味が悪い…」と感じた経験があるかもしれません。MonoBehaviourが多いコードベースの難しさの言語化はそれ自体も難しいのですが、素朴に「ピュアなC#クラスは読みやすい」という原点に立ち返ると分かりやすいです。
- 依存解決がやりやすい(Zenjectとの相性もよい)
- readonlyなデータを定義しやすく、Immutableなクラスを書きやすい
- etc…
MonoBehaviourを最小限に抑えたUnityコードはメンテナンス性がワンランク上がります。clusterでは実際にそうしているので、ぜひ今からグッドプラクティスとして実践してください!
バーチャルタスク: フレンドの入室を通知してみる
ここまでは一方的に説明していく形でしたが、そろそろ手を動かします。
あなたは本日クラスターに入社したUnity Engineerなので、まずはユーザーから変化が見え、かつサクッと実装できるタスクをやってもらいます。
ここで紹介するのはあくまで仮想的なタスクの事例ですが、実務としても仕事で最初に行うのはこういった「見える、軽めの」タスクとなります。プロジェクトの雰囲気を知り、アプリのリリースフローへの理解を深める、というのが狙いになっています。
で。何のタスクをやるかですが、clusterにはフレンドという機能があります。このフレンドが同じワールドに入室したことを知りやすくするため、フレンドの入室を通知でお知らせする機能を作ってみましょう。
ここで実際の資料なら
- 何は実装し、何は既存の実装が使えるのか
- 実装の枠組みを作ってから中身を詰めていく
といった順序で話が進むんですが、今回は抜粋なので3分クッキング方式を採用します。まず実装範囲と実装せずに済む範囲が定まって、
新規に実装するコード(2ファイル)のうち片方がこんな感じで、
// プレゼンテーション層 (FrendEnterNotifier.cs) using System; //... using Cysharp.Threading.Tasks; using UniRx; using Zenject; namespace ClusterVR.Presentation.Room { // フレンドが入室したら通知してくれるクラス public sealed class FriendEnterNotifier : IInitializable, IDisposable { // clusterの独自クラス (今回つくる) readonly FriendEnterNotifyUseCase useCase; // clusterの独自クラス (元からある) readonly NotificationBroker notificationBroker; // UniRxの標準的なクラス readonly CompositeDisposable disposable = new(); [Inject] FriendEnterNotifier(FriendEnterNotifyUseCase useCase, NotificationBroker notificationBroker) { this.useCase = useCase; this.notificationBroker = notificationBroker; } void IInitializable.Initialize() { var canceller = new CancellationDisposable(); disposable.Add(canceller); InitializeAsync(canceller.Token).Forget(); } async UniTaskVoid InitializeAsync(CancellationToken cancellationToken) { await useCase.RefreshFriends(cancellationToken); useCase.FriendEnterToRoomAsObservable() //profilesには入室した1-n人のフレンドのプロフィールが入っている .Subscribe(profiles => { notificationBroker.Publish(new Notification( //※実際のコードでは多言語対応をします "フレンドが入室しました! " + //UserNameはstringと等価なValueObject string.Join(", ", profiles.Select(p => p.UserName.Value)) )); }) .AddTo(disposable); } void IDisposable.Dispose() => disposable.Dispose(); } }
もう一方のコード実装とあわせてインストーラ層で依存注入して、
//RoomBaseInstaller.cs //(usingとnamespaceは略) public class RoomBaseInstaller : Installer<RoomBaseInstaller> { public override void InstallBindings() { //… Container.Bind<FriendEnterNotifyUseCase>().AsSingle(); Container.BindInterfacesTo<FriendEnterNotifier>().AsSingle(); } }
完成です!
コードは完成物だけだと何がなんだか分からないはずなので、雰囲気だけ感じ取ってもらえれば十分です。UniRxやZenjectの使い方のほか、もしかしたらコード規約も想像できるかもしれません。
(※社内用のUnity C# コード規約は本資料とは別で存在します。)
ちなみに今回のタスクですが、実は1回ぶんの実装でモバイル、デスクトップ、VRが全て動きます。嬉しいですね。
あなたは本日クラスターに入社したUnity Engineerなので、「マルチプラットフォームでのコードの共通化が可能かどうかは内容次第である」ということは覚えておいてください。
今回は「フレンド」の「入室」に対する「通知」という、プラットフォーム間で共通に扱える概念に基づいて開発したため、実際にコードが共通化できました。
この変更は(※これが本当のタスクなら)Pull Requestをコードレビューしてマージ、次週にコードフリーズしてRC(Release Candidate)ビルドに反映、QAを経てリリース、という流れでユーザーに届きます。
おわりに
駆け足でしたが、以上がオンボーディング資料こと「本日クラスターに入社したUnity Engineerが読む記事」の抜粋紹介でした。
ちょっと言及した部分もあったとおり、実際の資料では更に色々な角度からプロジェクト/コードの特徴を説明しています。
- クラスターのリアルタイム通信実装はなぜ入社後すぐだととっつきにくいのか
- バーチャル空間のライフサイクルの注意点
- 階層構造化したコードのアンチパターン
- RxのHot/Coldという言い方でコード構造を見るとどうなっているのか
- コードはどんな感じで書き進むのか
- レガシーコードにはどういう経緯があり、どう消しているか
Unityクライアントとしてのclusterはビルド~コードの粒度で色々な設計の側面を持っています。これらの設計で一家言ある人は、Unity開発体験向上の会で設計改善に取り組んでもらうと良いかもしれません。
ここまで繰り返し「あなたは本日クラスターに入社したUnity Engineerなので…」と言い続けて来ましたが、もし真の意味で「本日クラスターに入社したUnity Engineer」になることを検討してもらえれば幸いです!
クラスターではUnity Engineerを含めた様々な職種のメンバーを募集しています!