Try   HackMD

俺のスマホで動かすと世界最強のUnity製自作Androidアプリが海外ユーザの端末だと正常に動作しなくなってバグ取りに追われるも、不具合がおきる端末が手元に無いので無双苦労する話

タイトルはふざけていますが、真面目なお話です。

Who are you?

休日ゲームアプリ開発者で、現在、位置情報ゲーム Treasure Farmerを作っています。
簡単に言うとPokem○n Goとかドラ○エウォークみたいな感じのゲームです。
まだまだ荒削りで、コンテンツの追加も遅くあまりプレイヤーもいませんが、
無料でGoogle Playからダウンロードできますので良かったらプレイしてみてください。

こんなアプリ作ってます。

ダウンロードURL

https://play.google.com/store/apps/details?id=viosProject.TreasureFarmer

ゲーム画面

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

公式アカウント

Twitter
Discord

何もしてないのに壊れた!

先日、チェコ在住のプレイヤーからアプリを更新したら地図に敵や宝箱が表示されなくなったというメールが届きました。

実は何もしていないことはなく、アプリのゲームエンジンのUnityをUnity2019からUnity2020に更新しました。ただ、私のスマホ(P30 Lite, Android9)だと問題なく、動作することや、他のプレイヤー(おそらく日本在住)に確認しても問題なさそうだったので、かなり謎でした。

さては位置情報か

位置情報ゲームなので日本だと問題ないけど、チェコの緯度、経度だと問題が起きる不具合があるのかと思い、チェコの座標をエミュレートするアプリを自身のスマホに入れてテストしました。



何も起きませんでした。(不具合確認できず)

私、プライバシーにはちょっとうるさいので

個人的に過剰に情報を取られるのは嫌いなので、このアプリでも必要以上に情報を集めていませんでした。そのためUnityでエラーが起きようが例外が発生しようが私のところに自動的に情報が集まるような仕組みはありませんでした。

エラーレポートってどうやんの

エラーレポートする仕組みを作ったことがなかったので色々とググることになりました。
ググった結果、GoogleのFirebaseUnityがアプリに組み込むことでエラー情報を集積してくれるサービスを提供しているようでした。

た、たかい…

Unityの方は無料だと1日5件までエラー情報を保存してくれるようです。たださすがに5件は少なすぎるので諦めました。もちろん有料だともっと保存できるのですが、価格が趣味でアプリを作っている人間にはちょっと手が出せない価格でした。もっと低機能でいいので手頃な価格帯の料金プランがあれば良かったです。

い、いぞん関係が…

Google Firebaseの分析サービスは無料で使えるため、これを使うことにしました。
しかし、このサービスを利用するためにはFirebaseネイティブプラグインをアプリに組み込む必要があります。
これがなかなか曲者で、導入が難しく、ググってみると同じように苦しんでいる開発者が結構いるようです。特に私の場合は既にAdMob(アプリに広告をだすやつ)プラグインをいれており、それと一部ファイルが競合しているのかアプリのビルドすらできなくなりました。

ミイラ取りがミイラに

そもそもバグを発見するためにやっている行為がさらなるバグを呼びそうな感じがしてきたのでFirebsaeも諦めました。

最後に選ばれたのは自作でした

結局クラウドサービスには頼らずC#で自作しました。
エラーレポート作成に同意が得られている端末で例外が発生したらスタックトレースなどを記載したメール画面が立ち上がるシンプルなものです。

Application.logMessageReceivedに例外やエラー発生時に実行したい関数を登録すると
例外発生時に簡単に関数を呼び出すことができました。便利ですね。

スタックトレースの行番号について

説明は省略しますが、Unityはビルドする時にMonoかIL2CPPのどちらでビルドするか選べます。
そしてIL2CPPでビルドした場合、通常スタックトレースで行番号を確認することはできません。

Androidアプリの場合、現在Android携帯で広く使われているのがARMアーキテクチャCPUであることからARMでビルドする必要があります。そしてそのためにはIL2CPPでビルドする必要があり、結果として例外が発生しても行番号まではわかりません(関数名は分かります)。結構不便ですね。

例外発見

エラーレポートメールを受信

上記エラーレポート機能をアプリに組み込んでアプリを更新した翌日、最初に不具合を報告してくれた
プレイヤーからエラーレポートメールが送られてきました。

スタックトレースの一部抜粋 Mono.Data.Sqlite.SQLite3.Prepare (Mono.Data.Sqlite.SqliteConnection cnn, System.String strSql, Mono.Data.Sqlite.SqliteStatement previous, System.UInt32 timeoutMS, System.String& strRemain) (at <00000000000000000000000000000000>:0) Mono.Data.Sqlite.SqliteCommand.BuildNextCommand () (at <00000000000000000000000000000000>:0) Mono.Data.Sqlite.SqliteDataReader.NextResult () (at <00000000000000000000000000000000>:0) Mono.Data.Sqlite.SqliteCommand.ExecuteReader (System.Data.CommandBehavior behavior) (at <00000000000000000000000000000000>:0) Mono.Data.Sqlite.SqliteCommand.ExecuteNonQuery () (at <00000000000000000000000000000000>:0) DBHelper.Insert (System.String dbPath, System.String insertQuery) (at <00000000000000000000000000000000>:0)

はい。位置情報関係なかったです。
sqliteが原因のようです。これで勝てる!

さあ、バグ取りの時間だ(取れてません)

なんでsqlite死んでしまうん

sqlite(データース)を使おうとして例外が発生したのは分かりましたが、
なぜ例外が発生するのかはスタックトレースだけでは分かりませんでした。
そのため少し考えて、おそらく例外が発生したユーザはアプリにファイルアクセスパーミッションを与えていないからだと推測しました。

しかし不具合が起きたプレイヤーにいくつか操作をしてもらって確認したところ、sqlite形式ではない通常のファイルは問題なくread,writeできることが判明しました。
そのためこの例外はファイルアクセスパーミッションとは関係ないということが分かりました

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

やっぱり不具合が起きる端末がほしい

仕事終わりや休日を使い1週間ほど頑張りましたが、結局分かったのは一部の端末でのみsqliteが正常に動作しない、ということと、それがUnity2019からUnity2020にしたタイミングであったことだけでした。
厄介なのが自身の環境では不具合が発生しないことで、自由に試せる不具合が起きる端末がないとこれ以上深い調査は難しそうです。
当初はAndroidエミュレータで不具合が起きるAPIレベルの端末を作ろうとしたのですが、そもそもARMアプリはエミュレータで動かせないようでした。

端末を借りる

そこで実機のレンタルサービスなどないか調べてみましたが、価格がなかなかで、趣味で頼めるようなものではありませんでした。

AWS Device Farm

さらに調べるとAWS Device Farmというサービスを発見しました。これはリモートで実機を操作することができるサービスのようで無料でもある程度試すことができます。
さっそく数年前に使っていたAWSアカウントでサービスを利用しようとしたのですが、AWSを長く使っていなかったためかアカウントがロックされておりサービスをりようできませんでした。
さらにその日は土曜日でサポートは平日しか対応していなく、アカウントロックを解除してもらうことはできませんでした。

注:後日分かりましたが、実は数年前に私自身がAWS契約を解約しており、そのためアカウントが使えない状態になっていました。なお解約して90日以上立つと同じメールアドレスではAWSサービスを使えなくなるようです。

Firebase Test Lab

ASWの他にFirebase Test Labというサービスを使うことで実機でテストすることができることを知りました。
こちらはリモートで自由に端末を操作出来るわけではないのですが、アプリをアップロードして、リモートにある実機上でテストコードを動かしたりできます。
さらにRoboというアプリを立ち上げて自動でタップしまくってくれるテスト方法もあり、こちらを使うとテストコードを書くことなく、ある程度はテストすることができます。
今回はアプリや不具合の性質からテストコードを書く羽目になりました。
↓こんな感じでテストを実行する端末を選べます。

Test Labを使ってみた

Game Loopテスト

Game LoopテストではAndroidManifestに指定されたタグを追記して、アプリ内にテスト用関数を実装することで、リモートの実機でテストすることができます。
具体的な方法では公式ページで日本語でも解説されていますので詳細は省きますが、UnityアプリのTest Labでのテストについてネット上で情報があまり無いようなので
参考のため私がGame Loopを無事動かすためにやったことを記載します。

Unityでテストしたい

まずManifestに以下を追記します。これでテスト走らせる側がアプリをテスト状態で起動できるようになります。

<intent-filter> <action android:name="com.google.intent.action.TEST_LOOP"/> <category android:name="android.intent.category.DEFAULT"/> <data android:mimeType="application/javascript"/> </intent-filter>

さらに、githubからGame LoopをUnityアプリで動かすためのunitypackageをダウンロードしてUnity Editorにimportします。

https://github.com/googlecodelabs/unity-firebase-test-lab-game-loop

そしてUnityの開始シーン内でAwakeなどテストコード実行用プログラムを呼び出してテストコードを実行できるようにします。

TestLabManagerは上記パッケージをいれると使えるようになるクラスで
_testLabManager = TestLabManager.Instantiate();
などとして最初にこのインスタンスを取得します。

で、このインスタンスのScenarioNumberにアクセスすることでテストに対応するシナリオナンバーを取得することができます。テストがない場合は-1が返ってきます。
また各テストシナリオが完了した時に _testLabManager.NotifyHarnessTestIsComplete();としてテスト完了を通知する必要があります。そうしないと多分次のテストが呼ばれないと思います。
なお、Test Labではlogcatの内容を確認できるのでUnityのprintデバッグでよくやるDebug.logなどの内容もTest Labで確認可能です。
他にもファイルに情報を出力する機能もあるようですが、今回は使いませんでした。

以下は私が急ごしらえで作ったテスト用のクラスです。


    public static class MyTestLabHandler
    {
        private static TestLabManager _testLabManager;
        static int runningScenarioNum = -1;
        public static IEnumerator CheckTestScenarioLoop()
        {
            _testLabManager = TestLabManager.Instantiate();
            var wait = new WaitForSeconds(1f);
            while (true)
            {
                Debug.Log($"ScenarioNumuber={_testLabManager.ScenarioNumber.ToString()}");
                if (IsNewScenario(_testLabManager.ScenarioNumber)) RunTest(_testLabManager.ScenarioNumber);
                yield return wait;
            }
        }

        static bool IsNewScenario(int scenarioNum)
        {
            return scenarioNum != runningScenarioNum;
        }

        static void RunTest(int scenarioNum)
        {
            runningScenarioNum = scenarioNum;
            switch (scenarioNum)
            {
                case 1: 
                    RunDBTest();
                    break;
                    
                case -1: 
                    Debug.Log("-1:No Test");
                    break;
                default:
                    //該当するテストがない場合はスキップする
                    SkipTest();
                    break;
                    
            }
        }

        static void RunDBTest()
        {
            Debug.Log("Start Run DB Test");
            DBTest.Start();
            _testLabManager.NotifyHarnessTestIsComplete();
        }

        static void SkipTest()
        {
            _testLabManager.NotifyHarnessTestIsComplete();
        }
        

    }

ここまでやってビルドして、Test Labで走らせてもいいのですが、自身の端末でTestが正常に動くのか確認する方法があります。

これも公式ドキュメントにかかれていることですが、 https://dl.google.com/firebase/testlab/testloopmanager.apk
からGame Loopテストがちゃんと実装できているのか手持ちの実機で試すためのアプリをダウンロードできます。
このアプリを起動するとGame Loopテストを実装したアプリ名がでてくるので、ここにアプリ名が出てこない場合はなにか失敗しています。多分AndroidManifestに問題がある可能性が高いです。

Game Loopラベルについて

今回はSQLiteのテストにしか使用していませんが、人によってはいろいろなテストコードを実行したい場合があると思います。そういう場合はテストごとにシナリオ番号を割り当てることでテスト開始時に実行するテストを選択してテストすることが可能です。シナリオ番号の割当はAndroidManifestで行います。
そして私はここで数時間つまづきました。

<meta-data android:name="com.google.test.loops" android:value="5" /> <meta-data android:name="com.google.test.loops.DB_TEST" android:value="1," />

情報が少ないことと、失敗を繰り返したことからあまり自身はありませんが、最初のメタタグはテストシナリオ総数を指定しているものと思われます。
そして次のメタタグのvalueでシナリオ番号を、nameのところでシナリオ番号を区別するために使うラベルを指定でできます。
ラベルの指定というのは
android:name="com.google.test.loops.DB_TEST"
となっていrところのDB_TESTの部分を好きな文字列にすることができ、それをラベルとして使うことができます。

ここで問題となってくるのがvalueの項目で1,としています。これだとシナリオ番号1番がDB_TESTということなんえすが、最初、この項目にカンマをつけずに1としていました。
すると、なんとラベルとして認識されずテスト確認用アプリではテストを実行できなくなってしまいました。
そこで公式ドキュメントの例をみながら試行錯誤した結果数字の後にカンマを付けると何故か動作することが分かり無事テストを実行できるようにできました。
ちなみにその後カンマを抜いてもう一度試したところ、なんと問題なくテストすることができました。今でも謎です。ですので、もしテストが認識されないようでしたら一度カンマを入れてみてください。

ちなみに公式のサンプルでは

<meta-data android:name="com.google.test.loops.LABEL_NAME" android:value="1,3-5" />

となっており、シナリオ1,3,4,5番はLABEL_NAMEというラベルのテストであると宣言しています。
このように1つのラベルに複数のシナリオを紐付けることができるので、複数シナリオがある前提で文字列をパースして失敗しているのかもしれないです。

RuntimePermissionについて

Unity外部ストレージへのアクセスなどのパーミッションが必要になっている場合は起動時にパーミッション確認ダイアログをだす仕様になっているようで、私のアプリでも起動時にダイアログがでます。
これが厄介で、このダイアログが表示されている間はAwakeが呼ばれないようで、Game Loopテストがいつまで立っても始まらないことが判明しました。

対処法はいくつかあるのかと思いますが、私は急いでいたので、UnityのPlayer設定で外部ストレージにアクセスしないようにすることでダイアログがでなくなりました。

料金について

料金について書くのを忘れていました。
Test Labでは実機のテストは1日5端末まで無料で行えます。
有料プランにした場合は1時間あたり$5必要になります。ただしこれは1分単位で課金の対象になるようなので、1分以内のテストであれば60回してようやく$5で、実機をレンタルすることに比べればかなりお得です。

不具合が起きる端末がなかなか見つからず、私は結局有料プランにしました。

現状

現在、まずはTest Labで利用可能な実機のうちsqliteに問題がおきる端末をGame Loopテストで探しています。この文章を書いている時点で30端末ほど試してみましたが、どれも問題なく動作してしまいます。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

また続報があればここに追記したいと思います。

おしまし。

2021/01/26 追記

昨晩気づいたのですが、エラレポートの実装にミスがあり、例外名(〜Exception)を取得できていなくて例外が起きた関数名しか分からない状態でした。なので、アプリを更新して例外名を取れるようにしました。

アプリ更新後にエラーレポートを送ってもらったところ、
Exception: SQLite error
8 values for 6 columns

という、超訳すると「データ入れる箇所は6箇所なのに8個もデータ入れようとすんなや!」
という例外が発生していたことが分かりました。

ユニットテストなんてない

ので書きました。全く無いわけではないのですが、該当関数については作っていなかったのでリファクタリングしながら作りました。

しかし同じ例外は一度も起こせませんでした。

さらにTest Labでテストする

実はこの不具合チェコのユーザだけでなくフランス語ユーザからも報告されていて、端末名も教えてもらっていました。しかもこの端末、Test Labにあるんですね。
なので同じ端末を選んでテストしたのですが、やはり不具合は発生しなくて???でした。

もうやけになって不具合が起きる端末を探すべく、トータルで60端末くらい使ってテストしましたが、同じ問題は(HTCの端末だとなぜかアプリが落ちる新しい不具合は確認できましたが見なかったことにしました。)発生せず途方にくれました。

え、これ言語設定変えれるんですか?

Test Labでテスト端末を指定するとき、こんな感じでどれも英語設定になっていて、 英語しか選べないのか使えねえなあと少し思っていたのですが、じつはこれは「選ぶ」のではなく、設定したい言語を入力することでその言語に設定することができます。

これに気づいたのがついさっきで、さっそくフランス語に設定してテストしてみました。

バグ★再現

フランス語に設定してテストしたところ、なんとバグを再現することができました!
ついでに自分の日本語設定の端末もフランス語に設定したところ同様にバグってくれました!

そこで不具合が起きているチェコとフランスのプレイヤーにOS言語を英語に設定してアプリを試してもらったところ、不具合が発生しなくなりました。

なぜフランス語とチェコ語設定だとバグるのか

ググると少し似たような問題をGithubのissueに最近上がっていることが分かりました。
詳しくは後日追記したいと思います。私はググるまで分かりませんでしたが、考えてみると面白いかもしれないです。

ヒントは、数字と文化の違いです。

1/27 追記

バグの原因はヨーロッパ圏では小数点として.ではなく,が使われるからで
参考URL↓
https://qiita.com/koji-oura/items/50287bd9718d8a009df8

floatをToString()したときに、実行環境によって出力される文字列が異なる場合があった訳です。

で、これをSQL文に使うと小数点がSQL文の区切り文字として認識されてしまい、想定外の動作を引き起こしていました。

対処法

どうすればよかったのか

エラーレポートの仕組みは多少面倒でも実装しておくべき

本件ではエラーレポートを実装して初めてSQLiteで例外発生していること分かりました。
業務開発でなくても、エラーレポートくらいは実装しておくといいですね。最初から実装していれば
解決まで数日早かったと思います。
クラウドサービスもありますが、最初は同意を得ているユーザから例外発生時にメールで送ってもらう方法が実装も簡単でおすすめです。ユーザとのコミュニケーションも取りやすいです。