やらなイカ?

たぶん、iOS/Androidアプリの開発・テスト関係。

Unity x WebGLのテストTips

今週は、Unity 1週間ゲームジャム 第22回が開催されています。 今回のお題は「そろえる」とのことで、何かをそろえたら消えて得点が入るような振る舞いの自動テストを書きたくなる方もいるのではないでしょうか。

Unity 1週間ゲームジャムの投稿先であるフリーゲーム投稿サイトunityroomには、プラットフォームに「WebGL」を選択してビルド・アップロードを行ないます。 WebGLプラットフォームには、一部Unity Test Frameworkの制限があります。そのあたりを改めて検証してみました。

検証環境

  • Unity 2021.3.1f1
  • Unity Test Framework 1.1.31

Unity 2020以前の場合、本記事に記載していない問題を確認しています*1。 また、Unity 2021.3ではWebGLのビルドがかなり早くなっています*2。 どうしても古いバージョンを使わなければならない事情でもない限り、Unity 2021.3で開発することをおすすめします。

Edit ModeテストおよびIn Editor実行の制限

Edit Modeテストおよび、Play ModeテストをUnityエディター上で実行する限り、プラットフォームがWebGLであっても制限はありません。 そしてほとんどの場合、テストはUnityエディター上で動作させれば十分です。 つまり、

勝ったッ! 第3部完!

ということで、以下はおまけです。

プレイヤー実行の制限

本セクションの制限事項は、Unityエディター側の対応により Unity 2020.3.42f1, 2021.3.8f1, 2022.1.12f1, 2022.2.0b3, 2023.1.0a4 では解消しています。 詳しくは次の記事を参照してください [2023/1/6]

www.nowsprinting.com

Test Runnerウィンドウで「PlayMode」を選択しているとき、ウィンドウ右上にある「Run All Tests(WebGL*3)」をクリックすると下図のようにWebブラウザが開き、その中でWebGLアプリケーションとしてテストが実行されます。

以下、WebGLプレイヤー実行における問題点と制限事項を紹介します。

テスト実行結果がTest Runnerウィンドウに反映されない

他のプラットフォームではテスト実行結果はTest Runnerウィンドウに反映されますが、WebGLではされません。 "Receiving test data from player" と表示されるプログレスバーが止まったままとなり、キャンセルするしかありません。

どのテストが失敗したかを知るには、ブラウザ上のメッセージを読み取るか、WebブラウザJavaScriptコンソールでログを読むしかないようです。

docs.unity3d.com

UnityTest属性を使用できない

これはUnity Test Frameworkのドキュメントにも記載されている公の制限事項です。 UnityTest属性による非同期テストをWebGLプレイヤーで実行すると、 "Unhandled log message: '[Error] [xxx] The message header is corrupted and for security reasons connection will be terminated.'. Use UnityEngine.TestTools.LogAssert.Expect" というメッセージで失敗します。これはテスト実行中にLogErrorが吐かれたことに起因するメッセージであり、肝心のLogErrorで何が書かれているかは読み取れません。

この問題を回避するには、UnityTest属性で書かれたテストをWebGLプレイヤーで実行しないよう、次のようにUnityPlatform属性のexcludeパラメーターで除外指定します。

[UnityTest]
[UnityPlatform(exclude = new[] { RuntimePlatform.WebGLPlayer })]
public IEnumerator 非同期テストメソッド()
{
    _cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
    yield return null;

    Assert.That((bool)_cube, Is.True);
}

このテストはWebGLプレイヤーでは実行されなくなります。同等のテストをWebGLプレイヤーでも実行したいのであれば、なんとか同期テストとして書き直すしかありません。

UnitySetUp属性を使用できない

これも恐らくUnityTest属性と同じ理由で使用できないようです。 しかし、UnityTearDown属性は(Unity Test Framework v1時点では)WebGLでも動作します。ふしぎ。

こちらはSetUpメソッドのみ除外指定しても意味ないはずなので、UnityPlatform属性をテストクラスにつけることでクラスごと除外指定するとよいでしょう。

なお、UnityPlatform属性はメソッド、クラスのほか、アセンブリにも指定できます。

Unity Test Framework v2での制限

続いて、Unity Test Framework 2.0.1-pre.18を使っての検証結果です。プレリリース版ですので、今後緩和される可能性はあります。

非同期テストのサポートが最大の売りであるUnity Test Framework v2ですが、上記v1の制限事項は引き続き残り、追加で以下の制限があります。

非同期テストは使用できない

v2で追加された非同期テスト(async tests)ですが、これもWebGLでは使用できないようです。

UnityTearDown属性を使用できない

v1ではなぜか使用できたUnityTearDown属性は、v2では使用できなくなったようです。

まとめ

WebGLのテストは、Unityエディター上で実行する限り問題なし!

参考

Unity Test Frameworkについてはこちらの同人誌を参照してください。Unity Test Framework v2対応となる第2版に無償アップデートを予定していますので、買い控える必要はありません!

www.nowsprinting.com

Unity Test Framework v2についてはこちらを参照。

www.nowsprinting.com

*1:発生条件や修正されたバージョンの特定まで行なう時間がなく未調査です

*2:これも正確にどのバージョンからかは未調査です

*3:WebGL」部分は現在選択されているプラットフォーム名が入ります

diff-pdfでPDFファイルの視覚的差分を取るGitHub Action

diff-pdfは、オープンソースのPDFファイル比較ツールです。 これをGitHub Actionsワークフローから使用するためのDockerイメージ及びActionを作りましたので紹介します。

github.com

diff-pdfには、指定するオプションによって大きく2通りの用途があります。

  1. PDFを出力するシステムの検証のため、出力されたPDFが期待するものと等しいものかを検証する(ビジュアルリグレッションテスト、ゴールデンテスト)
  2. Re:VIEWMarpのようなテキストベースの原稿をPDFなどにレンダリングするツールを使用して執筆している場合に、テキストでなくレンダリング結果の差分*1を確認するためのPDFを生成する

以下、それぞれの用途に向けたワークフローの記述例を紹介します。

PDFが期待するものと等しいかを検証する

あらかじめ用意したexpected.pdfと、システムで出力したactual.pdfを次のように指定します。

- uses: nowsprinting/diff-pdf-action@v1
  with:
    file1: expected.pdf
    file2: actual.pdf

PDF同士が不一致のとき、このステップはエラーとなります。

差分PDFを生成する

例えばPull Request(以下PR)における差分を確認したいときは、PRのHEADコミットとBASEコミットそれぞれでレンダリングを実行し、得られたhead.pdfbase.pdfを次のように指定します。

- uses: nowsprinting/diff-pdf-action@v1
  with:
    file1: base.pdf
    file2: head.pdf
    options: --output-diff=diff.pdf
    suppress-diff-error: true

suppress-diff-error: trueと指定することにより、2つのPDFが不一致であってもこのステップはエラーになりません。 そして--output-diffオプションで指定したパスに、次のような赤青に色付けされた差分PDFファイルが出力されます。

なお、--skip-identicalオプションを付けると差分のないページを省略したPDFファイルが出力されて便利です。 また--mark-differencesを付けると上図のようにページ左に差分箇所を示す赤いマークが出力されます。

PRの差分PDFを出力するワークフロー全文

参考までに、PRにラベルdiff-pdfを付けたときのみ差分PDFファイルを出力するワークフローの例を貼っておきます。 このままコピペすれば動きます。

name: Diff pdf

on:
  pull_request:
    types: [ opened, synchronize, reopened, labeled ] # Note: labelを外した契機では動かない

env:
  REVIEW_CONFIG_FILE: 'config.yml'  # 代表的な設定ファイルを指定

jobs:
  diff-pdf:
    if: |
      contains(github.event.pull_request.labels.*.name, 'diff-pdf') ||
      ((github.event.action == 'labeled') && (github.event.label.name == 'diff-pdf'))
    runs-on: ubuntu-latest
    container:
      image: vvakame/review:5.3 # 原稿のRe:VIEWバージョンと一致するイメージを指定

    steps:
      - name: Checkout head
        uses: actions/checkout@v3
        with:
          fetch-depth: 100

      - name: Build head PDF
        run: |
          REVIEW_CONFIG_FILE=${{ env.REVIEW_CONFIG_FILE }} rake pdf
          mv ./*.pdf ../head.pdf
        working-directory: articles

      - name: Checkout base
        run: |
          git config --system --add safe.directory /__w/REPO_NAME/REPO_NAME # REPO_NAMEにはリポジトリ名を記述
          git checkout ${{ github.event.pull_request.base.sha }}

      - name: Build base PDF
        run: |
          REVIEW_CONFIG_FILE=${{ env.REVIEW_CONFIG_FILE }} rake pdf
          mv ./*.pdf ../base.pdf
        working-directory: articles

      - name: Diff PDFs
        uses: nowsprinting/diff-pdf-action@v1
        with:
          file1: base.pdf
          file2: head.pdf
          options: --verbose --skip-identical --mark-differences --output-diff=diff.pdf --dpi=100
          suppress-diff-error: true
        # Note: `--skip-identical`は差分が無い場合でも、空白1ページのpdfが出力される

      - uses: actions/upload-artifact@v3
        with:
          name: Output diff PDF
          path: diff.pdf

コンテナで使用する

diff-pdf-actionで使用しているDockerイメージはGitHub Container Registryに公開しています*2

コンテナは次のように使用できます。 diff-pdfを何度も実行するワークフローであれば、コンテナを使用するほうがオーバーヘッドが抑えられて高速になるはずです。

jobs:
  diff:
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/nowsprinting/diff-pdf:latest

    steps:
      - uses: actions/checkout@v3
      - run: diff-pdf --verbose expected1.pdf actual1.pdf
      - run: diff-pdf --verbose expected2.pdf actual2.pdf
      - run: diff-pdf --verbose expected3.pdf actual3.pdf
      :

*1:PDF比較ツールには、テキストを比較するものが比較的多くありますが、diff-pdfは視覚的に(画像として)比較してくれるツールです

*2:Docker Hubには置いていないので注意

Unity Test Framework v2.0ファーストインプレッション

Unity公式のユニットテストレームワークであるUnity Test Frameworkのバージョン2.0がプレリリースされたので早速触ってみました。 Unity 2022.2からデフォルトバージョンに採用される予定で、現在フォーラムでフィードバックを受け付けています

前提Unityバージョンは、v1のUnity 2019.2から少し上がり、Unity 2019.4になっていました。

アップグレード方法

プレリリースバージョンなので、デフォルトではPackage Managerには表示されません。 メニューから Edit | Project Settings... | Package Manager を開き、Enable Pre-release Packagesチェックボックスをonにすると、Package Managerでアップグレードできるようになります。

もしくはOpenUPM-CLIでバージョンを指定してインストールします。

$ openupm add com.unity.test-framework@2.0.1-pre.18

もしくは直接Packages/manifest.jsonの該当行を次のように書き換えます。

"com.unity.test-framework": "2.0.1-pre.18",

JetBrains Riderからのテスト実行は特に問題なさそうでしたが、何らかの問題が出る恐れもありますので、Riderを使う場合はできるだけRider Editorプラグインも最新版を使っていくほうがいいでしょう。

主要な新機能

Edit Mode/ Play Modeテストの結合

結合というより「相互乗り入れ可能」みたいなニュアンスです。Edit Mode/ Play Modeテストの概念が無くなったわけではありません。

具体的には、次のようになりました。

  • Edit Modeテストアセンブリ内のテストにRequiresPlayMode属性をつけると*1、Play Modeテストとして実行される
  • Play Modeテストアセンブリ内のテストにRequiresPlayMode(false)属性をつけると、Edit Modeテストとして実行される

従って、従来のEdit Mode/ Play Modeテストアセンブリはそのまま使えます。 その上で、今後は1アセンブリにEdit Mode/ Play Modeテストを詰め込むことも可能になった、ということです。

非同期テスト

v1.1では非同期処理のテストはUnityTest属性をつけたコルーチンしかサポートされていませんでした。 v2.0からは、次のようにTest属性のテストとしてasyncメソッドを呼び出すことができます。

[Test]
public async Task 非同期テストの例_Taskをawaitできる()
{
    await Foo(1);
    var actual = await Bar(2);
    Assert.That(actual, Is.EqualTo(3));
}

private static async Task Foo(int id)
{
    await Task.Delay(200);
}

private static async Task<int> Bar(int id)
{
    await Task.Delay(200);
    return id + 1;
}

もちろんUniTaskも使用できます。

[Test]
public async Task 非同期テストの例_UniTaskをawaitできる()
{
    await UniTaskFoo(1);
    var actual = await UniTaskBar(2);
    Assert.That(actual, Is.EqualTo(3));
}

private static async UniTask UniTaskFoo(int id)
{
    await UniTask.Delay(200);
}

private static async UniTask<int> UniTaskBar(int id)
{
    await UniTask.Delay(200);
    return id + 1;
}

v1.1でもUnityTest属性とUniTask.ToCoroutine()を組み合わせることでasync/awaitのテストは実現できましたが、UnityTest属性によるテストでは一部パラメタライズドテスト向けの属性の使用などに制約がありました。 その制限が無くなったことは、v2.0の最大のメリットと言えるでしょう。

[2023/1/28追記] Unity Test Framework v1.3で非同期テストが前倒し実装されました。詳しくは次の記事を参照してください。

www.nowsprinting.com

ParameterizedIgnore属性

パラメタライズドテスト向けのValues属性およびValueSource属性は指定したパラメーターの総当りで組み合わせが作られます。 v2.0で追加されたParameterizedIgnore属性を使用すると、実行したくない組み合わせをスキップできます。

次の場合、組み合わせは3x3=9通り作られますが、2件はスキップされ、実行されるのは7件になります*2

[Test]
[ParameterizedIgnore(Element.Fire, Element.Metal)]
[ParameterizedIgnore(Element.Water, Element.Earth)]
public void GetDamageMultiplier_ParameterizedIgnoreの使用例_指定した組み合わせはskipされる(
    [Values(Element.Wood, Element.Fire, Element.Water)] Element def,
    [Values(Element.None, Element.Earth, Element.Metal)] Element atk)
{
    var actual = def.GetDamageMultiplier(atk);
    Assert.That(actual, Is.EqualTo(1.0f));
}

Test Runnerウィンドウの変更

ボタンの位置などが変更されていますが、最も大きいのは、Edit Mode/ Play Modeの切り替えがチェックボックスに変わり、両方同時に指定できるようになったことでしょう*3

またカテゴリ指定の"Nothing"と"Everything"がなくなり、直感的な操作で指定できるようになりました*4

さらに、従来スタンドアロンプレイヤーでは実行するテストを選択できず、常に全件実行でしたが、v2.0では選択したテストのみスタンドアロンプレイヤーで実行できるようになりました。

その他の変更

使用できるNUnit3 APIなどにも変化があると思うのですが、未調査です。

サンプルコード

今回試したコードは、『Unity Test Framework完全攻略ガイド』サンプルコードのutf2ブランチに上げてあります。 このブランチをそのままマージすることはないと思いますが、ご参考になれば。

github.com

Unity Test Framework完全攻略ガイドの更新予定

『Unity Test Framework完全攻略ガイド』もv2.0への追随を予定しています*5。 名目上「第2版」に改版するつもりですが、現在販売している商品のアップデートで対応するつもりです。 (つまり、買い控えしなくて大丈夫です!)

www.nowsprinting.com

ただ、改版とは別に、Kindle版およびAmazon PODによるオンデマンド印刷版の発行を検討しています。 「Kindle版が欲しいがpdfと二重に購入したくない」という方は、もう少しお待ちいただいてもいいかもしれません。

参考

docs.unity3d.com

forum.unity.com

*1:Assembly, Class, Methodに付けられます。Assemblyに付けるのは本末転倒感がありますが

*2:組み合わせが減るわけではありません

*3:Edit Mode/ Play Mode混在の場合、すべてのEdit Modeテスト(RequiresPlayModeAttribute(false)も含む)を実行 → Enter Play Mode → すべてのPlay Modeテスト(RequiresPlayModeAttribute(true)も含む)を実行、の順に動作します

*4:Uncategorized(Category属性のついていないテストのみ)は健在

*5:以前からv1.2リリースの匂わせがあったので覚悟は完了していました

Unity Automated QA攻略ガイド

UnityのUIテスト補助ライブラリであるAutomated QAパッケージの解説本を、 コミックマーケット99の2日目(金曜日)東ト38b*1いか小屋」で頒布します。

Automated QAパッケージは、今年4月にアルファ版としてv0.2.0がリリースされた後もバージョンアップが重ねられている実験的(Experimental)パッケージです。 本書は最新のv0.8.1での各機能の紹介からTips、実験的パッケージならではのトラブルシューティングまで盛り込みました。

B5判40ページで頒布価格は1,000円。 J-Coin PayおよびKyashでのお支払いに対応します。 C99は物理本のみで、電子版は来年1/22開催の技術書典12での頒布を予定しています。

BOOTHにて物理本の通販を開始しました[1/1追記]。電子版は1/22に追加予定です[1/18追記] ikagoya.booth.pm

技術書典マーケットはこちらです。1/22 10:00販売開始予定です[1/18追記] techbookfest.org

目次の紹介

各章の内容は次のとおりです。

第1章 Automated QA概要

Automated QAパッケージの概要、ユースケース(用途ごとに第2章以降で紹介している機能をどう使うか)、セットアップ方法を解説しています。

第2章 Recorded Playback

Automated QAパッケージの主要機能である、Unityエディター上でUI操作を記録し、再生する機能を解説しています。 基本機能のほか、ランダムな操作を行なうGame Crawler機能、HTMLレポート機能についても解説しています。

「2.6 Input Systemサポート」についてのフォローアップ記事を書きました [2022/11/11追記]

www.nowsprinting.com

第3章 Test Generation

Recorded Playback機能で記録した操作から、Unity Test Frameworkのテストコードを生成する機能を解説しています。 テストコードの生成方法は3パターン提供されており、それぞれの特徴を紹介しています。 また、生成されたテストによる検証のメカニズムと、より厳密な検証を行なうためのアサーションの書きかたを解説しています。

第4章 Test Driver API

Recorded Playback機能によらず、直接テストコードやランタイムからUI操作を行なうためのAPIを紹介しています。 ただし、現時点では制約も多く、またテストコードのメンテナンスを考えるとお勧めしづらい機能のため、ごく簡単に紹介するにとどめています。

第5章 Automators

複数の操作記録の再生を連続実行できる機能を解説しています。 また、記録の再生のほか、ランダムな操作を行なうGame CrawlerやSceneのロードを行なうAutomatorの紹介、カスタムAutomatorを自作する方法も紹介しています。

第6章 Unity Test Framework Tips

Test Generation機能で生成されたテストコードはUnity Test Framework上で実行されます。 拙著『Unity Test Framework完全攻略ガイド』(下参照)から、直接関連するTipsを一部抜粋して紹介しています。

www.nowsprinting.com

付録A トラブルシュート

Automated QAパッケージは実験的パッケージです。フォーラムで不具合報告などしてきましたが、機能開発を優先されているようで修正されていない問題も残っています。 Automated QAパッケージを使うにあたって遭遇するであろうエラーについて、その原因と回避方法を紹介しています。

付録B UPMパッケージにパッチをあてる

Automated QAはUPM(Unity Package Manager)パッケージとして提供されています*2。 これに対し、手元の開発環境で独自のパッチを当てて運用する方法について紹介しています。

付録C Test Runnerウィンドウの使いかた

UnityエディターのTest Runnerウィンドウの使いかたを紹介しています。 本章は『Unity Test Framework完全攻略ガイド』からの抜粋です。v1.1.0で追加しました。

表紙について

[1/22追記]

かわいいSDユニティちゃんを描いていただいたのは、りょふ彦(@ryofu1323)さん。 例の猫のポーズで検品を行なう姿には、Automated QAパッケージの限界を表現したいという意図があります。

それは、Recorded Playback機能によるUI操作の再生をTest Generation機能で自動テストとして動作させる、またGame Crawler機能によるモンキーテストで得られるものは

「とりあえずエラーが出てないからヨシ!」

という温度感だということです。

ゲームが本当に意図通り動作していることを確認し、安心してリリースするためには、「3.4.2 アサーション」で紹介している厳密なテストコードを書いたり、その他さまざまな観点でのテスト実施が必要です。 私はAutomated QAはとても役に立つパッケージだと信じていますが、万能ではないのだということを認識していただけたら嬉しいです。

また、書名の「完全」を黒線で消しているのは、Automated QAが実験的(Experimental)なパッケージであることに起因しています*3。 いつか正式リリースを迎え、本書も改版とともに黒線を消す日が来ることでしょう。

最後に

C99はチケット抽選制で、すでに二次販売も終了しています。 参加したかったがチケット確保できなかったという方もいらっしゃるでしょう(私も1日目のチケット取れませんでした)。 物理本の売れ残りはBOOTHで頒布予定ですので、ショップをフォローしてお待ちいただけますと幸いです。

ikagoya.booth.pm

*1:日本Androidの会Unity部さんのお隣です。UNIBOOK13のついでにお立ち寄りください

*2:しかも、2022年後半まで開発保留と発表されたので、その間バグフィックスされません

*3:実装されている機能をすべて網羅しきれていない意味もありますが

メソッド分割の意義とTips

この記事は DeNA Advent Calendar 2021 16日目の記事です。

ユニットテストを書こうとしたとき、テスト対象のクラスやメソッドが大きく複雑なため断念した経験は誰しもあるのではないでしょうか。

ひとつのメソッドや関数に様々な処理・責務を持たせてしまうとテスタビリティ*1だけでなく、保守性や可読性も損なわれてしまいます。 これは「ロボット掃除機が掃除するためには部屋が片付いていなければならない」こと*2と似ています。

本記事では、メソッドを適切な単位に分割することの意義と、そのためのTipsを紹介します。

メソッド分割のPros and Cons

はじめに、メソッド分割の代表的なPros/ Consを整理してみます。

Pros

  • 再利用性の向上
    • 同一もしくは類似するロジックや計算式が複数箇所で必要になるケースは珍しくありません。メソッドを細かいロジック単位に分割することで、再利用が進むことはイメージしやすいはずです
    • 逆に言うと、再利用が少なく複数のメソッドに同じロジックが重複して存在している状態では、そのロジックに変更が入るときに修正箇所が多くなり修正漏れの恐れも生じます
  • テスタビリティの向上
    • 責務の大きなメソッドは、必然的に入力(引数のほかインスタンスフィールドの参照も含みます)が多くなり、その組み合わせすべてをテストすることが困難になります
  • 可読性の向上
    • 大きなメソッドより小さなメソッドのほうが読みやすい
    • メソッドの名前と責務を明確に定義しやすい

Cons

  • メソッド呼び出し回数が増え、オーバーヘッドがかかる
    • メソッド呼び出しには、処理ステップの増加とスタック操作を伴います。呼び出し回数が増えれば無視できない性能劣化に繋がります

メソッド呼び出しの増加に対しては、コンパイラによる「インライン化(inlining)」と呼ばれる最適化によって緩和できます。 インライン化は必ず行われるとは限らず、またバイナリサイズが大きくなるという副作用*3もありますが、本記事では考えないことにします。

Unityにおけるインライン化については、次の記事で検証していますので参考にしてください。

www.nowsprinting.com

メソッド分割Tips

続いて、メソッドや関数の分割(抽出)をスムーズに行なうためのTipsを紹介します。

Divide and Conquer, Name and Conquer

クラスやメソッドを分割することで、その関心や責務をシンプルに理解しやすく、扱いやすくする手法を、征服・統治になぞらえて「分割統治(Divide and Conquer)法」と呼びます。 チームのコーディング規約などで、メソッドの長さや複雑度の指標(後述)を基準に、人間が理解できる粒度に分割する、という考え方です。

一方で、必要なものにまず名前を付けることで扱いやすくする手法が「Name and Conquer」です。うまく対比した日本語訳が無いのですが、命名(定義)による統治(征服)といった意味です。

たとえば Fizz Buzz の一部を次のように切り出してみます。

private static bool IsFizz(int i)
{
    return i % 3 == 0;
}

これはやや過剰な例ですが、マジックナンバーである 3 を「Fizzとすべき割り切れる数」として定数に定義することは自然に行われているはずです。 上例のようなシンプルなメソッドは、定数と大きな差はありません。 3 を定数とするよりも、判定ロジックを切り出して名前を付けるという選択です。

複雑度を指標にした分割

メソッドの複雑さを測る指標として、サイクロマティック複雑度(cyclomatic complexity)やコグニティブ複雑度(cognitive complexity)が知られています。

例えばJetBrains IDEsには、エディタタブ上にこれらの指標を表示してくれるプラグインが提供されています。 プラグインを導入することで、コーディング中に複雑なメソッドに気づくことができます。

詳しくは次の記事で紹介していますので参考にしてください。

www.nowsprinting.com

IDEリファクタリング機能を利用する

JetBrains IDEsには、リファクタリングの補助機能があります。 別メソッドに分割したいコードを選択した状態でクイックアクションから "Refactor This..." を選択、続いて "Extract Method" で次のダイアログが表示されます。

f:id:nowsprinting:20211215084228p:plain:w400

メソッド名やアクセス修飾子などを指定して実行すると、メソッドを分割できます。

参考

C#のインライン化についての参考記事。

ufcpp.net

Name and Conquerについて。ヨシュア・トゥリーの話もおすすめです。

objectclub.jp

分割したメソッドに命名するのが面倒に感じることは多々あります。またPull Request (Merge Request) レビューで命名についての指摘を遠慮したこともあるのではないでしょうか。 その点、ペアプロやモブプロで、その場であれこれ名前の候補を出し合えるのは良い体験だと思っています。 モブプロについては次の記事で紹介していますので参考にしてください。

www.nowsprinting.com

PR 1

C99(冬コミ)でUnity Automated QAパッケージの解説本を頒布します。 2日目(金曜日)東ト-38b*4でお待ちしております!

f:id:nowsprinting:20211216041250j:plain:w300

PR 2

この記事は DeNA Advent Calendar 2021 16日目の記事です。

DeNAでは今年、2021年度新卒エンジニア・2022年度新卒内定エンジニアの Advent Calendar もあります! 本 Advent Calendar とは違った種類、違った視点での記事をぜひお楽しみください!

DeNA 2021年度新卒エンジニア・2022年度新卒内定エンジニアによる Advent Calendar 2021 https://qiita.com/advent-calendar/2021/dena-21x22

また DeNA 公式 Twitter アカウント @DeNAxTech では、 Blog記事だけでなく様々な登壇の資料や動画も発信してます。ぜひフォローして下さい!

*1:ISTQB GLOSSARY Ver3.2(日本語版)では「試験性」と称しています

*2:「ルンバビリティ」と呼ばれます

*3:これはそもそもメソッド分割前の状態に戻ることなので本記事では無視していいでしょう

*4:日本Androidの会Unity部さんのお隣です。UNIBOOKお買い求めのついでにお立ち寄りください!

Unityにおけるインライン化

テスタビリティ*1など内部品質の文脈で、クラスやメソッドの責務を分けるべし、メソッド分割すべし、という話をよくします。

メソッド分割には、メソッド呼び出しが増えることによる実行時オーバーヘッド増加という問題があります。 これに対しては、

という方針できましたが、Unityにおいて根拠が乏しかったため、簡単に検証してみました。

インライン化

メソッド分割に起因するオーバーヘッドの増加は、コンパイラによる「インライン化(inlining)」または「インライン展開(inline expansion)」と呼ばれる最適化によって緩和できます。

インライン化とは、文字通り呼び出し先のメソッドを呼び出し元メソッド内に直接展開する最適化手法です。 インライン化にはメソッドのバイト数などの条件があるため必ず行われるとは限らず、またロジックのコピーが作られるためバイナリサイズが大きくなるデメリットがあります。

C#で書かれたコードは中間言語(IL)に翻訳されます。 .NETの場合はILを.NETランタイムで実行するときにJITコンパイラにより最適化されますが、UnityではILがIL2CPPによってC++コードに変換された後、AOTのC++コンパイラによってネイティブコードとなる際に最適化が行われます。

インライン化について、Unityマニュアルの 特殊な最適化 ページには次のように書かれています。

ここで主な問題は、 Unity がメソッドのインライン化をほとんど行わないことです。 IL2CPP 下であっても、多くのメソッドは現在、適切なインライン化を行いません。これは特にプロパティに関して言えることです。さらに、 virtual および interface メソッドのインライン化は一切行えません。

「ほとんど」とありますが、テスタビリティや可読性向上を目的としたメソッド分割の範囲で最適化されるのかを確認してみます。

検証

検証に使用した環境は

  • Unity 2020.3.21f1
  • プラットフォーム: macOS/x64

です。

次の2通りのコードを実行してみました。

public int Calculate(int count)
{
    var sum = 0;
    for (var i = 0; i < count; i++)
    {
        sum += i;
    }

    return sum;
}
private static int DividedMethod(int i)
{
    return i;
}

public int Calculate(int count)
{
    var sum = 0;
    for (var i = 0; i < count; i++)
    {
        sum += DividedMethod(i);
    }

    return sum;
}

count に100,000,000を与えたときの実行時間は次の通りです。なお、IL2CPPだけは精度が出なかったため少し呼び出し負荷を増しています。

Unityエディタ Mono IL2CPP
メソッド分割なし (No Divide) 約 270 [ms] 約 49 [ms] 約 3.5 [ms]
メソッド分割あり (Divided) 約 660 [ms] 約 65 [ms] 約 23 [ms]

メソッド分割有無であきらかな性能差が出ています。

続いて、別のアセンブリに定義されたメソッドを呼ぶケースも検証するため*3、3パターン追加します。

  • アセンブリにあるメソッドを呼ぶ (Another Assembly)
  • アセンブリにあるメソッドに MethodImpl(MethodImplOptions.AggressiveInlining) 属性を付ける (Aggressive Inlining)
  • アセンブリにあるメソッドに MethodImpl(MethodImplOptions.NoInlining) 属性を付ける (No Inlining)

AggressiveInlining は、その名の通り積極的にインライン化を行なうように指示するものです。 .NETのJITコンパイラが解釈する属性ですが、UnityのIL2CPPもこれを解釈してインライン化されやすいC++コードを出力してくれるようです。

結果は次のようになりました。

Unityエディタ

f:id:nowsprinting:20211215074846j:plain:w400

Mono

f:id:nowsprinting:20211215050856p:plain:w400

IL2CPP

f:id:nowsprinting:20211216062846p:plain:w400

No Inliningのケースではアセンブリを超えてメソッドコールが行われるため、IL2CPPでも時間がかかっています。

Another Assemblyのケースは AggressiveInlining を指定していないのですが、指定したときと同様に呼び出し元のcppファイルにメソッドのコピーが作られており、コンパイル時にインライン化(もしくは類する最適化)されているようです。

Dividedのケースでは同一cppファイルにメソッドはあるものの、最適化はされていないか、限定的であることがうかがえます。

まとめ

  • 実行性能が気になる場合はプロパティでなくメソッドを使用する
  • インライン化してほしいメソッドには MethodImpl(MethodImplOptions.AggressiveInlining) 属性を付ける

検証コードは下記リポジトリに置いてあります。

github.com

参考

ufcpp.net

notnullvariable.com

lpha-z.hatenablog.com

learning.unity3d.jp

*1:ISTQB GLOSSARY Ver3.2(日本語版)では「試験性」と称しています

*2:ボトルネックに当たったときに対処するという方針。「早すぎる最適化は諸悪の根源(Premature optimization is the root of all evil)」という言葉は有名です

*3:IL2CPPにおいては、アセンブリ単位にcppファイルが生成されます。通常、別のcppファイルに定義されたメソッドはインライン化されません

カスタムRoslyn AnalyzerをUPMパッケージとして配布する

Unity 2020.2でRoslynアナライザによる静的解析を行えるようになりましたが*1、NuPkgやDLLで配布してUnityプロジェクトに導入するのはやや面倒です。

そこで、アナライザをUnity Package Manager (UPM) パッケージとして配布する方法を紹介します。アナライザ単体での配布はもちろん、自作ライブラリに関連するアナライザをライブラリのUPMに同梱して配布できます。

前提は、Unity 2020.3.4以降です。UnityバージョンごとのRoslynアナライザまわりの機能制限については次の記事にまとめています(Unity 2021.2の差分まで反映済)。

www.nowsprinting.com

アナライザ入りUPMパッケージの作成

アナライザをNuGetから取得もしくはビルドしたDLLは、一旦Assetsフォルダ下に置いて*2 Inspectorウィンドウを開き、次のようにmeta情報を設定します。

  • Select platforms for pluginをすべてoff
  • Asset Labelsに RoslynAnalyzer を設定

"Apply"をクリックして適用したら、アナライザDLLを配布するUPMパッケージ下に移動します。 このとき必ず、Assembly Definition File (.asmdef) の影響下に配置してください*3

例えば次のようなディレクトリ構成にします。 ライブラリに同梱するときは、パスの途中に他の.asmdefを置いてしまうと別アセンブリ扱いになりますので注意してください。

Packages/your.library
├── Editor
│   └── YourLibrary.Editor.asmdef
├── Runtime
│   ├── Analyzers
│   │   ├── your.library.analyzers.dll
│   │   └── your.library.analyzers.dll.meta
│   ├── YourLibrary.Runtime.asmdef
│   └── YourLibraryCode.cs
├── Tests
│   └── YourLibrary.Tests.asmdef
├── package.json
└── package.json.meta

これで、このUPMパッケージを使用するプロジェクトでは、アナライザを含んだアセンブリ(上例では YourLibrary.Runtime )を"Assembly Definition References"に追加することで(そのアセンブリ内でのみ)アナライザが有効になります。

※ただし、Unity Editor上では。

なお、ライブラリに同梱でなくアナライザだけを配布したい場合でも、アセンブリが作られる必要があります。そのため、asmdef下にはDLLだけでなく、何かしらダミーのC#スクリプトファイルも置く必要がある点に注意してください。[2/6追記]

IDEでの振る舞い[1/4追記]

Unityに対応しているIDE(JetBrains Rider, Visual Studio, Visual Studio Code)には、Unityエディターのメニューから "Open C# Project" を実行したときに.slnおよび.csprojファイルを生成してくれるプラグインパッケージが提供されています。

プラグインパッケージが生成する.csprojファイルが上記Unityエディターと同じ振る舞いになる*4のは、次のバージョン以降です。

  • Unity 2020.3.6以降、もしくは Unity 2021.1.2以降

かつ

IDEプラグインパッケージ向けのAnalyzer Importerスクリプト

本記事執筆時点で、各IDE(JetBrains Rider, Visual Studio Code, Visual Studio)のプラグイン*5はRoslynアナライザに正しく対応していません。

例えばJetBrains Rider Editor package v3.0.7では、Assets下および、Packages直下に実体のある組み込みパッケージ (Embedded package) のみが、.asmdefの依存関係に関係なく適用されます。

IDE側の対応がなされるまでは 上記バージョンの組み合わせを満たせないプロジェクトでは、次のスクリプトをUPMパッケージに同梱して配布するとよいでしょう。 パス設定など不要で、そのままアナライザと同じ.asmdef下に放り込めば動くようになっています。

gist.github.com

ただし、以下の制限事項があります。

  • .asmdefのファイル名(拡張子は除く)と、アセンブリ名(Inspectorウィンドウの"Name")を一致させてください
  • .asmdefの参照の連鎖には対応していません。アセンブリA(ここにアナライザ) ← アセンブリB ← アセンブリC と参照した場合、アナライザが有効になるのはアセンブリBまでです

UPMパッケージに組み込んだもの(テスト付き)はこちら。デフォルトブランチでないので注意。

github.com

アナライザの有効範囲についての補足

アナライザをインポートしたプロジェクト側では、Unity Editorでは.rulesetファイル、Riderでは.editorconfigファイルによって診断の重大度 (Severty) を制御できます。 従って、Severtyをnoneにすることで実質的にアナライザを無効化することはできます。

しかし、.rulesetは影響範囲をアセンブリつまり.asmdef単位で指定する必要があり、アナライザおよびアセンブリが多数あるプロジェクトでは管理が難しくなってきます。

.editorconfigではパスで影響範囲を指定できるため.rulesetよりは楽ですが、やはり.asmdefの依存関係による有効範囲制御が好ましいと思います。

Define Constraintsについての補足[2022/11/22追記]

アナライザのDLLでは、DLLのDefine Constraints設定は無視されるようです。 一方、.asmdefファイルのDefine Constraints設定は有効です。

つまり、例えばUnityバージョンによって有効なアナライザを切り替えたいとき、フォルダを複数用意してそれぞれに置いた.asmdefのDefine Constraintsに条件を書く必要があります。

UPMパッケージの対応Unityバージョンについて[11/1追記]

UPMパッケージのマニフェストファイル (package.json) には、パッケージが動作する最低Unityバージョンを指定できます。

アナライザのみを配布するパッケージであれば、次のようにUnity 2020.3.6f1*6と各IDEsプラグインパッケージのバージョンを指定することで「インストールしたけれど動作しない」といった混乱を回避できます。 unityRelease 属性はUnity 2020.3で追加されたものですが、それ以前のバージョンは unity 属性で除外できるので問題ないでしょう。

{
  "name": "your.analyzer.name",
  "version" "1.0",
  "unity": "2020.3",
  "unityRelease": "6f1",
  "dependencies": {
    "com.unity.ide.rider": "3.0.9",
    "com.unity.ide.visualstudio": "2.0.11",
    "com.unity.ide.vscode": "1.2.4"
  }
}

実際にNUnit.AnalyzersをUPMパッケージ化したものを作ってみましたので参考にしてください。 ただし、Unity Editor上でこのアナライザが動作するのはUnity 2021.2以降のため*7unity属性は2021.2にしてあります。

github.com

ただ、この指定では「Unity Editorでは動作しなくていいがIDEで使いたい」というニーズをカバーできないのが悩みどころです。

また、ライブラリにアナライザを同梱する場合には、特に最低Unityバージョンを変更する必要はないでしょう。 Unity 2020.3.3以前であっても、アナライザが動作しないだけで悪影響はありません。

参考

docs.unity3d.com

docs.unity3d.com

qiita.com

github.com

github.com

*1:Roslynアナライザの作成・導入方法はこちらにまとまっています https://swet.dena.com/entry/2021/05/25/100000

*2:Packages下に置くとInspectorウィンドウでAsset Labelsが設定できません

*3:もし、UPMパッケージをインポートしたプロジェクトの全コードに対して無条件に適用してよいアナライザであるなら、アナライザを.asmdef下に置かないで配布することもできます。また、.asmdefのAuto Referencedをonにすると、Assembly-CSharpアセンブリに含まれるコードに対して自動で適用されます。Assembly-CSharp-Editorには適用されません

*4:具体的には、該当csproj中に<Analyzer>ノードが挿入される

*5:Unityのメニューから"Open C# Project"を実行したとき、.slnおよび.csprojファイルを生成する役割

*6:ただしこの設定では2021.1.0f1〜2021.1.2f1未満が漏れるため、安全に倒したければ2021.1.2f1以降としてください

*7:NUnit.AnalyzersがMicrosoft.CodeAnalysis.CSharp v3.6以降を使ってビルドされているため