Share
Embed
DeNA TechCon 2021 - スマホ向けゲームの辛い部分をコード自動生成技術で克服する / Overcoming the Painful Part of Smartphone Games Development with Automatic Code Generation
2021/03/03 に開催されるDeNA TechCon 2021の発表資料です。
スマホ向けゲーム開発で書くことが多いブリッジコードを、Protocol Buffers Compiler Pluginを使って自動生成するという内容です。詳しくは公式ページのセッション概要をご参照ください。
公開日時: 2021/03/03 13:00@Track C
TOYAMA Sumio
March 03, 2021
Transcript
80 slides
-
2 自己紹介 p 氏名: 外山 純生 (TOYAMA Sumio) @sumio_tym (Twitter)
/ @sumio (GitHub) p 所属: DeNA SWETグループ(Software Engineer in Test) / ソリューション事業本部(兼務) p 業務内容: 品質のボトルネック解決 (主にAndroid) p その他: 「Androidテスト全書」執筆 https://peaks.cc/sumio_tym/android_testing -
3 お話しすること スマホゲームのブリッジコード自動生成ツール開発 で得られた知見を紹介します (現時点で完成しているAndroid側のみ) p コード自動生成ツール開発のきっかけと概要 p コード自動生成する上でのポイント 異なるプログラミング言語間の
呼び出しを実現するコード (後で詳しく説明します) Unity -
4 話の流れ 1. 背景 p ネイティブライブラリとブリッジコード p コード自動生成ツール開発のきっかけ p 何故Protocol
Buffersにしたのか 2. 開発したコード自動生成ツールの概要 3. コード自動生成する上でのポイント p protoスキーマ定義 p KotlinリフレクションAPIを使ったコード解析 4. コード自動生成ツールにバグを入れ込まない工夫 -
5 1. 背景 p ネイティブライブラリとブリッジコード p コード自動生成ツール開発のきっかけ p 何故Protocol Buffersにしたのか
2. 開発したコード自動生成ツールの概要 3. コード自動生成する上でのポイント p protoスキーマ定義 p KotlinリフレクションAPIを使ったコード解析 4. コード自動生成ツールにバグを入れ込まない工夫 -
6 Unityについて p ゲームエンジンのひとつ https://unity.com/ p クロスプラットフォーム p C#で書けば、原則Android/iOS両対応のゲームが作れる p
上記で書けないAndroid/iOSの機能を使いたいときは p Android/iOSのネイティブライブラリ(後述)を呼び出せる p ネイティブライブラリ呼び出しにはブリッジコード(後述)が必要 -
8 ネイティブライブラリのユースケース p ネイティブならではの機能を使いたい p 課金 p ネイティブ向けのみ提供のライブラリを使いたい p サードパーティ製のライブラリ
p 複数のゲームエンジンから利用したい p Unity・Cocos2d-x両方から使いたい共通ロジック -
9 ブリッジコード p 異なるプログラミング言語間の呼び出しコード p ネイティブライブラリを呼ぶために必要 Unity (C#) fun doSomething(
param: MyData, callback: ((MyResult) -> Unit) ) Android (Kotlin) doSomething(myData, myCallback) myCallback(myResult) ブリッジコード (言語の境界を越えるために特別なルールに従う必要) -
10 Android→Unity呼び出し時のルール p 呼び出し先(callee)を指定するとき p クラス名やメソッド名は文字列で指定 p 引数を指定するとき p 引数の数は1つだけ
p 引数の型は文字列だけ ルールの内容は 以下の組み合わせによって それぞれ異なります • ゲームエンジンの種類 • Android or iOS • 呼び出す方向 -
11 Android→Unity呼び出しのコード例 val unityPlayer = Class.forName("com.unity3d.player.UnityPlayer") val sendMessage = unityPlayer.getMethod("UnitySendMessage",
String::class.java, String::class.java, String::class.java) sendMessage.invoke(null, "MyGame", "Foo", "MyParam") MyGameのFooメソッドの引数に"MyParam"を指定 して呼び出す場合 文字列のメソッド名 引数1つ。文字列型のみ -
Android層 Unity層 12 ルールを踏まえたブリッジ処理の流れ UnityからdoSomething(myData, myCallback)を呼ぶ例 フェーズ1: 開始 ①コールバック登録用IDを払い出す ②myCallbackをテーブルに登録
③myDataとIDを文字列にシリアライズ ④Android側の_doSomething(③)を呼ぶ ID コールバック 1 myCallback 2 ... -
Android層 Unity層 13 ルールを踏まえたブリッジ処理の流れ UnityからdoSomething(myData, myCallback)を呼ぶ例 フェーズ1: 開始 ①コールバック登録用IDを払い出す ②myCallbackをテーブルに登録
③myDataとIDを文字列にシリアライズ ④Android側の_doSomething(③)を呼ぶ ID コールバック 1 myCallback 2 ... フェーズ2: _doSomething(③)の処理 ⑤③をデシリアライズしてIDとmyDataを復元 ⑥本来の処理を呼ぶ doSomething(myData) { result -> ⑧以降の処理(ID, result) } -
Android層 Unity層 14 ルールを踏まえたブリッジ処理の流れ UnityからdoSomething(myData, myCallback)を呼ぶ例 フェーズ1: 開始 ①コールバック登録用IDを払い出す ②myCallbackをテーブルに登録
③myDataとIDを文字列にシリアライズ ④Android側の_doSomething(③)を呼ぶ ID コールバック 1 myCallback 2 ... フェーズ2: _doSomething(③)の処理 ⑤③をデシリアライズしてIDとmyDataを復元 ⑥本来の処理を呼ぶ doSomething(myData) { result -> ⑧以降の処理(ID, result) } ⑦本来のdoSomething()が完了。 処理結果myResultを引数にコールバックが呼ばれる -
Android層 Unity層 15 ルールを踏まえたブリッジ処理の流れ UnityからdoSomething(myData, myCallback)を呼ぶ例 フェーズ1: 開始 ①コールバック登録用IDを払い出す ②myCallbackをテーブルに登録
③myDataとIDを文字列にシリアライズ ④Android側の_doSomething(③)を呼ぶ ID コールバック 1 myCallback 2 ... フェーズ2: _doSomething(③)の処理 ⑤③をデシリアライズしてIDとmyDataを復元 ⑥本来の処理を呼ぶ doSomething(myData) { result -> ⑧以降の処理(ID, result) } ⑦本来のdoSomething()が完了。 処理結果myResultを引数にコールバックが呼ばれる フェーズ3: コールバックの処理 ⑧処理結果myResultとIDを文字列にシリアライズ ⑨Unity側のHandleCallback(⑧)を呼ぶ -
Android層 Unity層 フェーズ4: HandleCallback(⑧)の処理 ⑩⑧をデシリアライズしてIDとmyResultを復元 ⑪IDからテーブルを引いてmyCallbackを取り出す ⑫myCallback(myResult)を呼ぶ 16 ルールを踏まえたブリッジ処理の流れ UnityからdoSomething(myData,
myCallback)を呼ぶ例 フェーズ1: 開始 ①コールバック登録用IDを払い出す ②myCallbackをテーブルに登録 ③myDataとIDを文字列にシリアライズ ④Android側の_doSomething(③)を呼ぶ ID コールバック 1 myCallback 2 ... フェーズ2: _doSomething(③)の処理 ⑤③をデシリアライズしてIDとmyDataを復元 ⑥本来の処理を呼ぶ doSomething(myData) { result -> ⑧以降の処理(ID, result) } ⑦本来のdoSomething()が完了。 処理結果myResultを引数にコールバックが呼ばれる フェーズ3: コールバックの処理 ⑧処理結果myResultとIDを文字列にシリアライズ ⑨Unity側のHandleCallback(⑧)を呼ぶ -
17 ここまでのまとめ p ネイティブライブラリ呼び出しには ブリッジコードが必要 p ブリッジ処理は制約を踏まえた実装が必要 p 呼び出し先は文字列で指定 p
引数列の(デ)シリアライズが必要 p JSON p Protocol Buffers p etc. -
18 1. 背景 p ネイティブライブラリとブリッジコード p コード自動生成ツール開発のきっかけ p 何故Protocol Buffersにしたのか
2. 開発したコード自動生成ツールの概要 3. コード自動生成する上でのポイント p protoスキーマ定義 p KotlinリフレクションAPIを使ったコード解析 4. コード自動生成ツールにバグを入れ込まない工夫 -
19 従前のブリッジコード 手書きで頑張っていた p 呼び出し先メソッド名の文字列 p 引数のシリアライズ・デシリアライズ p JSON (フィールド名文字列も手書き)!
Android iOS Unity Cocos2d-x ① ② ③ ④ ⑤ ⑥ ⑦ ⑧ -
20 従前のブリッジコードが抱えていた課題 p 文字列のtypoがコンパイル時に発見できない p 異常時にしか使われないJSONフィールドのtypoだと ずっと見付からないことも・・・ p JSONを介するやりとりが型安全ではない p
ブリッジコードが原因のバグの割合: 約1/6 p ブリッジコードの実装工数の割合: 約1/6 -
22 コード生成ツールの要件 p 既存の(自動生成しない部分の)コードへの影響が少ないこと p typoや型不一致をビルド時に発見できること p ブリッジコードを生成するとき p 生成されたコードをコンパイルするとき
p 複数言語で共通に使われるものを1箇所でマスター管理 できること p (デ)シリアライズ対象のデータ構造 (スキーマ定義) p メソッドシグネチャー (名前と引数の並び) -
23 採用した方式 (デ)シリアライズ形式 Protocol Buffers マスターデータ protoスキーマ定義ファイル コード生成方式 protocプラグイン (Protocol
Buffers Compiler Plugin) -
24 ここまでのまとめ p 手書きしたブリッジコードのミスをコンパイラが 発見するのは難しい p typoやJSONの型不一致など p ブリッジコード起因のバグも工数も1/6を占めていた p
ブリッジコードを自動生成することで解決したい p ビルド時にミスを発見し、二重管理を排除したい p Protocol Buffersを採用 -
25 1. 背景 p ネイティブライブラリとブリッジコード p コード自動生成ツール開発のきっかけ p 何故Protocol Buffersにしたのか
2. 開発したコード自動生成ツールの概要 3. コード自動生成する上でのポイント p protoスキーマ定義 p KotlinリフレクションAPIを使ったコード解析 4. コード自動生成ツールにバグを入れ込まない工夫 -
26 Protocol Buffersの特徴 https://developers.google.com/protocol-buffers p データのシリアライズ形式の1つ p バイナリフォーマット p データ構造(スキーマ)はprotoファイルに定義
p protoファイルから以下を各言語ごとに自動生成 (Protocol Buffers Compiler: protocコマンド) p protoスキーマに対応するクラス定義 型安全! typoや型不一致はコンパイルエラー -
27 Protocol Buffersの利用例 https://developers.google.com/protocol-buffers より message Person { optional string
name = 1; optional int32 id = 2; optional string email = 3; } person.proto (スキーマ定義) Person john = Person.newBuilder() .setId(1234) .setName("John Doe") .setEmail("[email protected]") .build(); FileOutputStream output = new FileOutputStream(args[0]); john.writeTo(output); Javaでシリアライズする例 -
28 protocコマンド message Person { optional string name = 1;
optional int32 id = 2; optional string email = 3; } person.proto (スキーマ定義) public final class PersonOuterClass { ... public static final class Person ... { ... public String getName() { ... } ... } } ./gen/PersonOuterClass.java protoc --java_out=lite:./gen person.proto ※Androidではliteオプションが必要 ※ -
29 protocプラグイン person.proto ./gen/PersonOuterClass.java OUT_DIR/{プラグインが生成したファイル} protoc --plugin=protoc-gen-NAME=path/to/myplugin --NAME_out=opt1=value1,opt2=value2,...:OUT_DIR --java_out=lite:./gen person.proto
NAME: プラグインの名前 path/to/myplugin: プラグイン実行ファイル (CLI) opt1=value1: プラグインに渡すオプション OUT_DIR: プラグインが生成するコードの出力先 検索キーワード: google.protobuf.compiler.plugin.h -
30 protocプラグインのインターフェイス p CLIプログラム p 標準入力: protoファイルに書かれている情報 p message内のフィールドの型や名前など p
protoファイルに自由に設定できるカスタムオプションの値 p 標準出力: 生成したいファイルの情報 p 標準入出力はprotobuf形式のバイナリデータ p google/protobuf/compiler/plugin.proto (GitHub.com) p google/protobuf/descriptor.proto (GitHub.com) protoファイルをマスターデータにできそう -
31 ふたたびコード生成ツールの要件 p 既存の(自動生成しない部分の)コードへの影響が少ないこと → (後述)protocが生成したコードはブリッジ内でしか使わない p typoや型不一致をビルド時に発見できること → protoc生成コードとの整合性は、その言語のコンパイラが保証!
→ protocプラグインで自動生成時のチェックも可能 p 複数言語で共通に使われるものを1箇所でマスター管理 できること → マスター管理対象が増えても、カスタムオプションとして定義 すればprotoファイル1つで管理できる! -
33 1. 背景 p ネイティブライブラリとブリッジコード p コード自動生成ツール開発のきっかけ p 何故Protocol Buffersにしたのか
2. 開発したコード自動生成ツールの概要 3. コード自動生成する上でのポイント p protoスキーマ定義 p KotlinリフレクションAPIを使ったコード解析 4. コード自動生成ツールにバグを入れ込まない工夫 -
34 自動生成の対象(コード生成ツールの出力) 現バージョンではAndroid層が対象 Android層 Unity層 フェーズ4: HandleCallback(⑧)の処理 ⑩⑧をデシリアライズしてIDとmyResultを復元 ⑪IDからテーブルを引いてmyCallbackを取り出す ⑫myCallback(myResult)を呼ぶ
34 フェーズ1: 開始 ①コールバック登録用IDを払い出す ②myCallbackをテーブルに登録 ③myDataとIDを文字列にシリアライズ ④Android側の_doSomething(③)を呼ぶ ID コールバック 1 myCallback 2 ... フェーズ2: _doSomething(③)の処理 ⑤③をデシリアライズしてIDとmyDataを復元 ⑥本来の処理を呼ぶ doSomething(myData) { result -> ⑧以降の処理(ID, result) } ⑦本来のdoSomething()が完了。 処理結果myResultを引数にコールバックが呼ばれる フェーズ3: コールバックの処理 ⑧処理結果myResultとIDを文字列にシリアライズ ⑨Unity側のHandleCallback(⑧)を呼ぶ この部分を自動生成する -
Android層 35 ⑤の部分をもう少し詳しく フェーズ2: _doSomething(③)の処理 ⑤③をデシリアライズしてIDとmyDataを復元 ⑥本来の処理を呼ぶ doSomething(myData) { result
-> ⑧以降の処理(ID, result) } フェーズ3: コールバックの処理 ⑧処理結果myResultとIDを文字列にシリアライズ ⑨Unity側のHandleCallback(⑧)を呼ぶ val wireBinary = ③から取り出したmyData部分のprotobufバイナリ val proto: PbMyData = ProtoMyData.parseFrom(wireBinary) val myData = toOurObject(proto) protocが生成したクラス 既存クラスにデータを詰め替える関数。本ツールで生成。 -
Android層 36 ⑧の部分をもう少し詳しく フェーズ2: _doSomething(③)の処理 ⑤③をデシリアライズしてIDとmyDataを復元 ⑥本来の処理を呼ぶ doSomething(myData) { result
-> ⑧以降の処理(ID, result) } フェーズ3: コールバックの処理 ⑧処理結果myResultとIDを文字列にシリアライズ ⑨Unity側のHandleCallback(⑧)を呼ぶ val proto : PbMyResult = toProtoObject(myResult) val base64 = Base64.encodeToString(proto.toByteArray(), NO_WRAP) val ⑧ = (IDとbinBase64をJSON配列に詰めたもの) protocが生成したクラス protocが生成したクラスへデータを詰め替える関数。本ツールで生成。 -
37 生成するものまとめ p ブリッジコード本体 p データを詰め替える関数 p 前掲のtoOurObject()・toProtoObject() p クラスごとに1組ずつ必要
フェーズ2: _doSomething(③)の処理 ⑤③をデシリアライズしてIDとmyDataを復元 ⑥本来の処理を呼ぶ doSomething(myData) { result -> ⑧以降の処理(ID, result) } フェーズ3: コールバックの処理 ⑧処理結果myResultとIDを文字列にシリアライズ ⑨Unity側のHandleCallback(⑧)を呼ぶ -
38 コード生成に必要な情報① p Unity側に公開したい関数の情報 fun doSomething2(MyData1, MyData2, (MyResult?, MyError?) ->
Unit)) p 関数の名前 p doSomething2 p 引数の並びと型(コールバックが受け取る引数も) p MyData1, MyData2, MyResult?, MyError? -
39 コード生成に必要な情報② p (デ)シリアライズが必要な型の情報 p 前頁で集めた引数の型のクラス定義 p そこから参照されているクラスの定義 MyData1 MyData2
MyResult MyError Foo Bar Baz doSomething2(MyData1, MyData2, (MyResult?, MyError?) -> Unit) -
40 コード生成に必要な情報② p (デ)シリアライズが必要な型の情報 p 前頁で集めた引数の型のクラス定義 p そこから参照されているクラスの定義 MyData1 MyData2
MyResult MyError Foo Bar Baz doSomething2(MyData1, MyData2, (MyResult?, MyError?) -> Unit) 必要な 情報の範囲 -
41 コード生成に必要な情報③ p (デ)シリアライズが必要な型に対応するprotoスキーマ定義 MyData2 MyResult MyError Foo Bar Baz
doSomething2(MyData1, MyData2, (MyResult?, MyError?) -> Unit) MyData1 message Foo { ... } message Bar { ... } message MyData2 { ... } message MyResult { ... } message MyResult { ... } message MyData1 { ... } message Baz { ... } -
42 コード生成に必要な情報まとめ 必要な情報 ① Unity側に公開する 関数の情報 ② (デ)シリアライズが 必要な型のクラス定義 ③
②に対応するproto スキーマ定義 -
43 コード生成に必要な情報まとめ 必要な情報 情報源 ① Unity側に公開する 関数の情報 ネイティブライブラリ ② (デ)シリアライズが
必要な型のクラス定義 ネイティブライブラリ ③ ②に対応するproto スキーマ定義 protoスキーマ定義 ファイル -
44 コード生成に必要な情報まとめ 必要な情報 情報源 アクセス手段 ① Unity側に公開する 関数の情報 ネイティブライブラリ Kotlin
リフレクションAPI ② (デ)シリアライズが 必要な型のクラス定義 ネイティブライブラリ Kotlin リフレクションAPI ③ ②に対応するproto スキーマ定義 protoスキーマ定義 ファイル protocプラグインの 標準入力 -
45 データの流れ ネイティブライブラリ.jar protoスキーマ定義 ファイル protocコマンド 本コード生成ツール (protocプラグイン) proto定義ファイルの情報 (code
generator request) 生成するファイルの情報 (code generator response) ダイナミックロード リフレクションAPIで情報取得 • Unity側に公開する関数(※)リスト • そこから使われる引数の情報 protocによる生成コード 本ツールによる生成コード 凡例 入力 既存ツール 開発対象 生成物 (入力) (入力) (既存ツール) (生成物) (生成物) (開発対象) ※Unity側に公開する関数には独自アノテーションを付けて区別可能にしています -
46 コード生成ツール導入前後の比較 Before After 手書き するもの ブリッジコード • (デ)シリアライズが必要なクラスに対応する protoスキーマ定義
• Unity側に公開する関数へのアノテーション付与 • (詳細は割愛)enum対応のための書き換え 自動生成 なし ブリッジコード Android iOS Unity Cocos2d-x 今回の範囲 -
47 ここまでのまとめ 開発したコード生成ツールの概要を説明しました p 入力: ネイティブライブラリとprotoスキーマ定義 p [アノテーション付与が必要] Unity側に公開する関数の情報 p
[自動取得] (デ)シリアライズが必要なクラスの定義 p [手書きが必要] そのprotoスキーマ定義 p 出力: ブリッジコード p ブリッジコード本体 p データを詰め替える関数 -
48 1. 背景 p ネイティブライブラリとブリッジコード p コード自動生成ツール開発のきっかけ p 何故Protocol Buffersにしたのか
2. 開発したコード自動生成ツールの概要 3. コード自動生成する上でのポイント p protoスキーマ定義 p KotlinリフレクションAPIを使ったコード解析 4. コード自動生成ツールにバグを入れ込まない工夫 -
49 protoスキーマ定義 ネイティブライブラリ.jar protoスキーマ定義 ファイル protocコマンド 本コード生成ツール (protocプラグイン) proto定義ファイルの情報 (code
generator request) 生成するファイルの情報 (code generator response) ダイナミックロード リフレクションAPIで情報取得 • Unity側に公開する関数(※)リスト • そこから使われる引数の情報 protocによる生成コード 本ツールによる生成コード 凡例 入力 既存ツール 開発対象 生成物 (入力) (入力) (既存ツール) (生成物) (生成物) (開発対象) ※Unity側に公開する関数には独自アノテーションを付けて区別可能にしています この部分 -
51 ネイティブライブラリ側のクラスと対応付ける① 対応付けによる効果 p データ詰め替え関数の引数・戻り値の型が確定する fun toProtoObject(ours: Person) : PbPerson
p 両者の矛盾を検知できる p フィールド名不一致 p 型不一致 p nullability不一致 -
52 ネイティブライブラリ側のクラスと対応付ける② protoファイルにカスタムオプションを定義して実現 package example data class Person( val name:
String, val tel: String? ) message PbPerson { option (example.message_options) = { java_name = "example.Person" }; optional string name = 1; ... } 対応するクラスのFQCNを 宣言してもらう ネイティブライブラリ クラス定義 protoスキーマ定義 -
53 nullabilityを扱えるようにする① p protobufの世界では原則nullable p non-nullを意味するrequiredキーワードは非推奨 p 値が未設定=nullという考え方 p 未設定のときはデフォルト値が返るので、
未設定のときの意図がどちらか判別できない p デフォルト値をセットしたつもり p nullをセットしたつもり -
55 nullabilityを扱えるようにする③ package example data class Person(val name: String, val
tel: String?) message PbPerson { ... optional string tel = 2 [(example.field_options) = { nullable: true }]; } nullabilityを宣言してもらう ネイティブライブラリ クラス定義 protoスキーマ定義 protoファイルにカスタムオプションを定義して実現 -
56 nullabilityを扱えるようにする④ package example data class Person(val name: String, val
tel: String?) // protoc生成クラスから詰め替える fun toOurObject(protoObject: PbPerson) : Person = Person(name = pbPerson.name, tel = if pbPerson.hasTel() pbPerson.tel else null) 生成コードにおけるnull/non-nullの違い -
57 特別な型に対応する① p protobufでシリアライズできるのはmessageだけ doSomething3(MyData1, List<MyData2>, (MyResult?, MyError?) -> Unit)
messageのリストなのでシリアライズ不可 message PbMyData2List { option (example.message_options) = { container_type: LIST }; repeated PbMyData2 my_data2 = 1; } ➜ ラップするmessageを明示的に定義する ラップしていることを カスタムオプションで宣言 -
58 特別な型に対応する② p protobufでnullがシリアライズできない doSomething3(MyData1, List<MyData2>, (MyResult?, MyError?) -> Unit)
これらの引数にnullが渡されるとシリアライズできない ➜ 複数の引数をJSON配列にまとめる時にJSON nullで表現 val json = JSONArray() val params = (引数リストに対応するprotoc生成のオブジェクト) params.forEach { if (it == null) json.put(JSONObject.NULL) else json.put(Base64.encodeToString(it.toByteArray(), ...) } sendMessage.invoke(null, "MyGame", "HandleCallback", json.toString()) -
59 ここまでのまとめ p protobufの世界と対象言語(ネイティブライブラリ)の世界 を対応付けるためにカスタムオプションを活用しよう p 対応クラス名、nullability宣言、etc. p コード生成時に整合性チェックできるようになる p
protobufで非対応の型は1つ1つ対応を検討する p messageのリスト、null、etc. -
60 1. 背景 p ネイティブライブラリとブリッジコード p コード自動生成ツール開発のきっかけ p 何故Protocol Buffersにしたのか
2. 開発したコード自動生成ツールの概要 3. コード自動生成する上でのポイント p protoスキーマ定義 p KotlinリフレクションAPIを使ったコード解析 4. コード自動生成ツールにバグを入れ込まない工夫 -
61 KotlinリフレクションAPIを使ったコード解析 ネイティブライブラリ.jar protoスキーマ定義 ファイル protocコマンド 本コード生成ツール (protocプラグイン) proto定義ファイルの情報 (code
generator request) 生成するファイルの情報 (code generator response) ダイナミックロード リフレクションAPIで情報取得 • Unity側に公開する関数(※)リスト • そこから使われる引数の情報 protocによる生成コード 本ツールによる生成コード 凡例 入力 既存ツール 開発対象 生成物 (入力) (入力) (既存ツール) (生成物) (生成物) (開発対象) ※Unity側に公開する関数には独自アノテーションを付けて区別可能にしています この部分 -
62 Kotlinリフレクションの用途 p コード生成に必要な情報のうち、 ネイティブライブラリにしか無い情報を取得する p Unity側に公開する関数の情報 p (デ)シリアライズが必要な型のクラス定義 p
protoスキーマ定義との整合性をチェックする p nullability不一致の検知 p etc. -
63 Kotlinリフクレションの沼 p 言語機能のサポート範囲を増やすほど複雑化・・ p Javaのサポート可否 p (デ)シリアライズ対象クラス定義の自由度 p (デ)シリアライズ対象の型の範囲
いかにスコープ(サポート範囲)を狭められるかが鍵 -
64 Javaのサポート可否① p 解析対象がJavaでも困ることは少ないが、 nullability判定が困難 p KTypeクラスのisMarkedNullableプロパティ p Javaのnullability annotationをサポートしているが、
AndroidXのアノテーションは対象外 p Java由来クラスかどうか判定するAPIも無い p kotlin.Metadataアノテーションで判定可能との情報あり https://stackoverflow.com/a/39806722/2925059 -
65 Javaのサポート可否② p Javaはサポート対象外にした p 正確には以下のコストを比較して判断 p 解析対象クラスをKotlinにコンバートするコスト (Android Studioの機能で簡単にコンバート可能)
p コード解析時にJava由来クラスだけ特別扱いするコスト p nullability以外にもgetterとpropertyの違いなど -
66 (デ)シリアライズ対象クラス定義の自由度① protoc生成クラスからデータを詰め替えるとき p どうやって初期化するか曖昧 p コンストラクタ引数で初期化 p 空のオブジェクト生成→各プロパティにセットして初期化 fun
toOurObject(protoObject: PbPerson) = Person(name = pbPerson.name, tel = ...) fun toOurObject(protoObject: PbPerson) = Person().also { it.name = pbPerson.name ... } -
67 (デ)シリアライズ対象クラス定義の自由度② p Kotlin data classに限定 p プロパティをコンストラクタで初期化する書き方が多い p コンストラクタ引数でのプロパティ初期化に限定
data class MyData(val prop1: String, val prop2: String?) data class MyData(val prop1: String) { var prop2: String? = null } -
68 (デ)シリアライズ対象の型の範囲① p protoスキーマ定義で表現不可なもの p ListのList p nullableなList p Generics
p 要素がnullableなList p Map (ハッシュテーブル) p 特別なmessageを用意すれば対応できるが、複雑度が増す ➜ どうしても必要になるまでサポートしない -
69 (デ)シリアライズ対象の型の範囲② p (Unity側に公開する関数の)コールバック引数 p λ式 p SAMインターフェイス p 複数メソッドを持つインターフェイス
p 範囲を広げるとコールバックに渡される引数の特定が困難に ➜ できるかぎり1種類に限定する ➜ Kotlinで良く使われるλ式のみに絞った (Function<out R>のサブタイプ) -
70 ここまでのまとめ ネイティブライブラリ側の自由度をできるだけ狭める p 自由度が高まると、解析ツールの実装が複雑に・・ p Javaはサポート対象外にする p 対応するprotoスキーマ定義が無いときに無理に対応しない p
ListのListなど p コールバック引数として指定できる型はλ式に限定する -
71 1. 背景 p ネイティブライブラリとブリッジコード p コード自動生成ツール開発のきっかけ p 何故Protocol Buffersにしたのか
2. 開発したコード自動生成ツールの概要 3. コード自動生成する上でのポイント p protoスキーマ定義 p KotlinリフレクションAPIを使ったコード解析 4. コード自動生成ツールにバグを入れ込まない工夫 -
73 生成コードにバグを入れないためには 次のどこか(できる限り④まで)で阻止(発見)できれば良い ① コード生成ツールによる整合性チェック p コード生成ツールへのユニットテストでチェックの正当性担保 ② ツールが生成したコードのコンパイル時チェック ③
ツールが生成したコードに対するユニットテスト ④ ゲーム(Unity)側との結合テスト(自動テスト) ⑤ 手動の結合テスト・E2Eテスト -
74 テスト観点ごとに発見すべき箇所を検討 ①ツール による チェック ②コ ンパ イラ ③生成 コードへの
テスト ④Unity 側との 結合 ⑤ 手動 protoスキーマ定義忘れ ◦ フィールド過不足 ◦ [データ詰め替え関数] プロパティが過不足なくコピーされているか ◦ 異なるプラットフォームで シリアライズ結果が一致しているか ◦ ネイティブの処理結果が コールバックを通じて正しく返ってくるか ◦ ・・・ 漏れているテストはこの時点で発見・追加 -
76 ここまでのまとめ p コード生成ツールに期待される品質レベルは高い p 「どの種類のバグをどの段階のテストで発見すべ きか」のマトリックスを作成することで達成 p 漏れていたテストはこの時点で追加 p
将来的には生成コードに対するテストコードも 自動生成できそう -
77 全体のまとめ p 制約の厳しいブリッジコードを手で書くのが辛いので、 自動生成するツールを開発しました (現時点で完成しているAndroid側のみ) p protocプラグインとKotlinリフレクションを使うことで 整合性のチェックも可能になった p
コード自動生成ツール開発のポイントを紹介しました p protoカスタムオプションで必要な情報を宣言する p ネイティブ側の言語機能の対応は必要最低限にする p テスト観点ごとにバグ発見箇所を検討したら満足する品質になった -
78 参考URL p Unity2019.4ユーザーズマニュアル「JARプラグイン」 https://docs.unity3d.com/ja/2019.4/Manual/AndroidJARPlugins.html p Protocol Buffers公式ドキュメント https://developers.google.com/protocol-buffers p
protocプラグインのコマンドラインオプションの仕様 https://developers.google.com/protocol- buffers/docs/reference/cpp/google.protobuf.compiler.plugin p 「protocプラグインとカスタムオプション」by @yugui https://qiita.com/yugui/items/29adefab34f7f1a3c3c6 p 「protocプラグインの書き方」 by @yugui https://qiita.com/yugui/items/87d00d77dee159e74886