Akatsuki Hackers Lab | 株式会社アカツキ(Akatsuki Inc.)

Akatsuki Hackers Labは株式会社アカツキが運営しています。

運用フェーズのプロダクトのCocos2d-xバージョンを3.2から3.17に一気に上げた時の知見

この記事は https://adventar.org/calendars/3952 3日目の記事です。

 

はじめに

 こんにちは。クライアントエンジニアのRiyaaaaaです。

 私が担当しているプロダクトではゲームエンジンとしてCocos2d-xを採用しています。リリース当初のバージョンは3.2で、それ以降4年間1度もアップデートされることはありませんでした。しかし、今年、関係各所の調整の結果、とうとうCocos2d-xを最新バージョンまで上げる大規模な改修バージョンのリリースに成功し、こうして記事の執筆に至った次第です。

 断っておきますが、執筆者である私はCocos2d-xバージョンアップ作業において、先行的な技術調査、最低限のゲームを動かすためのコンパイル作業や基幹システムへの修正等しか行なっておりません。Cocos2d-xバージョンアップという大きな改修は、多くのエンジニアの協力あって実現しました。この場を借りてお礼申し上げます。

 

Cocos2d-xバージョンアップを実行に移した経緯

 品質向上のためにもゲームエンジンのバージョンを上げたいという話は以前から上がっていましたが、プロダクトの視点から見ると、リスクの割にベネフィットが薄い点が否めません。そのため、問題意識はありながらも、開発項目に具体的に上がることはありませんでした。

 しかし、スマホアプリ業界に2つの大きな激震が走り、Cocos2d-xのバージョンアップはほぼ必須というところまで追い込まれてしまったのです。業界の方ならよくご存知でしょう。

 1つ目は、2018年6月ごろAppleが発表した、iOSのOpenGL ESの利用をdeprecatedとする発表です。つまり、将来的にOpenGL ESはサポートされなくなるので、Metalへ移行すべしというお達しです。

 2つ目は、2019年1月ごろGoogleが発表した、Google Playアプリは2019年8月をもって64bit対応を必須とする発表です。これはiOSの件よりも深刻でした。なぜなら、数年後といったスパンではなく、残り半年強という期限で対応を迫られていたからです。*1

 この2つの発表が決め手となり、ついにプロジェクトリーダー、版元様、ディレクター陣の合意を取り、現場のエンジニア、テスターなどのスタッフの大規模なリソースを注いでの、バージョンアップ作業が始まりました。

 

バージョンアップ作業黎明期、とりあえずゲームが動くまで

 さて、私はというと、AppleのOpenGL ESのdeprecated化の発表より、秘密裏にCocos2d-xバージョンアップ計画を進めておりました。私は将来的に必ず、ゲームエンジンのバージョンアップを迫られる日が来るという直感があったからです。*2 しかし、マイナーバージョンを2から17に上げるというアグレッシブな改修には、そもそも「可能である」ことの証明が必要です。そのため、開発の空き時間などを利用し、地道にローカルでバージョンアップ作業を進めていました。

 もちろんコンパイルすら通らないところからのスタートです。gitで専用のworkspaceを切って、毎日少しずつコンパイルエラーを減らしていきました。ようやくコンパイルが通ったところで、起動時クラッシュに直面。そこから数多くのファイルシステム、ネットワークシステム、その他クラッシュを引き起こす不具合を修正し、「とりあえず動く」状態に持って行きます。

 

Cocos2d-xバージョンアップの開発本格始動

 そして2019年初ごろ、Googleの64bit対応必須化発表の折、「Cocos2d-xアップデートなしには対応は不可能」という見解をエンジニアチームは出します。ndk-buildのabiFilterにarm64-v8aを追加する、そんな単純な話ではありません。特に問題になるのが、Third-pirty製静的ライブラリです。アプリ側のライブラリはもちろん、Cocos2d-xが依存する外部静的ライブラリもほぼ全てが64bitサポートされていませんでした*3。エンジンはアップデートすることなくライブラリだけアップデートする? そんなことが不可能なのは火を見るより明らかでした。

 しかし功を奏して、私の手元にはとりあえず一通り動作している最新Cocos2d-xリプレイス済みバイナリがありました。もちろん、遷移するだけでクラッシュする画面もあれば、どこもかしこもレイアウト崩れが起きまくっていましたが... 「バージョンアップ、イケると思います」

 こうして、Cocos2d-xバージョンアップ計画は、新規開発チーム全体を巻き込んで始まったのでした。

 ちなみに、私はCocos2d-xバージョンアップのメイン開発チームには加わっていません(!?)。弊プロダクトは定期的に大規模なイベントを開催しておりますが、その重要機能の開発に追われていたからです。残念なことです。しかし、ちょうどその時に社内から異動してきたエンジニアの方が低レイヤーなんでも御座れの超ベテランエンジニアだったので、代わりに担当していただき、結果的に素晴らしいアウトプットになりました。最後に紹介しようと思いますが、単なるCocos2dxアップデートに留まらない劇的なビルド環境改善に取り組んでくれたのです。
 

起こったバグ、修正方針まとめ

 以降は、事象、修正方針ベースで項目化して紹介していきます。あまりにも量が膨大だったため、漏れがあるかもしれませんが、参考にしてください。免責事項として以下に紹介する内容はあくまで弊プロダクトの修正方針であり、全てのプロジェクトで動作を保証するものではありません。また、私が実際に修正したわけではないものも多くありますが、誰かの役に立つことを願って、代理として紹介させていただきます。

1. ファイルシステムの大規模なコンフリクト

 弊プロダクトはCriFsというCRIWARE様のファイルシステムを採用しています。そのため、Cocos2d-xのFileUtilsをはじめとするファイルシステム関連クラスには独自拡張を大量に含んでおり、バージョンアップの際に大規模なコンフリクトを起こしました。気合で直しました。初っ端から参考にならない事例で申し訳ない... 

2. HttpClientクラスのレスポンスコードの取り扱いの変化

 元のcocos2d/cocos/network/HttpClientには、responseCodeが200以上、300未満ではない場合、タスクの失敗とする実装がありましたが、3.17のHttpClient-apple/HttpClient-androidクラスでは、それに該当するコードが無くなっていました。つまり、コールバックの取り扱いが変化していたのです。すると、アプリ側でFailureコールバックとしてハンドリングしていた部分が途端に動かなくなりました。

 そのため、挙動を元に戻すためにエンジンに手を加えました。例えば、HttpClient-apple::processTaskの末尾に3.2当時のと同等動作のコードを加えます。

static int processTask(HttpClient* client, HttpRequest* request, NSString* requestType, void* stream, long* responseCode, void* headerStream, char* errorBuffer)
{

  // 中略

  + if (!(*responseCode >= 200 && *responseCode < 300)) {
  +   return 0;
  + }

  return 1;

}

androidも同様です。

3. OpenSSL EVP_CIPHER_CTXのインスタンスの確保方法の変化

 aes_encrypt/aes_decryptに使う暗号情報のコンテキストの型EVP_CIPHER_CTXが、動的確保に変化しました。具体的には、EVP_CIPHER_CTXは前方宣言のみ提供され、今までのようにスタック変数として宣言することができなくなり、EVP_CIPHER_CTX_nex(), EVP_CIPHER_CTX_free()インターフェースで動的確保、解放することを要求されるようになりました。

4. FileUtils::fullPathForFileNameのファイルが見つからなかった時の挙動の変化

 以前の実装ではファイルが見つからなかった時の挙動が「ファイル名」を返す、という挙動でしたが、空文字列が返るように変更されています。妥当な修正ですが、以前の挙動を前提としたコードがあったので元に戻しました。

5. Spriteのレンダー用のコマンドクラスがQuadCommandからTrianglesCommandへ変化

 独自のSpriteを継承した描画クラスが、draw関数をオーバーライドして実装していましたが、その辺りが旧_quadCommandメンバにアクセスしていたため、_trianglesCommandに変更しました。インターフェース等は抽象クラスが同じなため変数差し替えだけで動作しています。

 

6. RefPtrが不完全な型を受け付けなくなった

 RefPtrにコンパイル時型検査が追加されました。

static_assert(std::is_base_of<Ref, typename std::remove_const<T>::type>::value, "T must be derived from Ref");

 std::is_base_ofは完全型を要求するので、既存のコードで前方宣言のみされた型をRefPtrとして定義しているところのコンパイルが通らなくなったのです。これはこのstatic_assertをコメントアウトするだけでも解決しますが、アプリ側の該当箇所が1箇所だったのでそちらを修正しました。

class A {

  class B {

    cocos2d::RefPtr<A> _ptr;

  };

};

 
 このように、インナークラスが外側のクラスのRefPtrを保持するような設計でしたが、これだとBの定義段階ではAは不完全型なので、インナークラスをやめて分離することで解決しました。

7. Scene::_childrenにカメラインスタンスが追加され、_childrenのインスタンスのインデックスが変化した

 これはかなり頭を悩ませました。なにせクラッシュを引き起こす上に、不正なstatic_castを起こして意味不明な場所でバグるのです。そんなコードが何十箇所も!!

 これははっきり言ってお見せするのも恥ずかしい典型的なアンチパターンのコードなのですが、画面の構築順序上「このインスタンスはchildrenのX番目にある」という危なげな根拠による、childrenメンバへのインデックスによるアクセスコードが大量にあったのです。
  

// X番目のChildはHogeクラスだからstatic_castするぜ

auto hoge = static_cast<Hoge*>(view->getChildren().at(X));


 これは最悪なコードです。まず前提としてこんなコードを書くべきではありません。ですが、開発黎明期に書かれたレガシーコードには多くのこういったコードが含まれていました。*4 しかも、たちの悪いことに、static_castを使用しているのです! 嗚呼、これがdynamic_castだったなら、即時にnullptrアクセスで落ちるのに。しかし、static_castは違うインスタンスポインタだろうが問答無用でキャストします。その結果、アクセスの仕方によって様々な挙動を見せます。これは低レイヤー言語の趣深い挙動ですね。クラッシュすればいい方です。

これはgrepで該当コードを探し出してひたすら修正しました。修正方針はそこの文脈で都度判断。ですが、原因の多くはSceneクラスを定義する時、Layerを継承して空のSceneにaddして返す、という古きCocos2d-xのお作法によるものが多かったです。つまり、あるSceneをcreateした時、返ってくるinstanceは目的のクラスではなく、空のSceneクラスだったのです。そのため、childrenから目的のクラスインスタンスを探すコードが多くありました。

auto scene = MyScene::create();

auto myClass = static_cast<MyScene*>(scene->getChildren.at(0)); // 最新バージョンでは、0番目はカメラのインスタンスのためNG

 
 実際にはSceneクラスを定義する時にそんな面倒なことをする必要はなくシンプルにSceneを継承すればいい話です。公式のサンプルコードも、すでにその黒歴史を修正しています。
don't use Layer by minggo · Pull Request #17048 · cocos2d/cocos2d-x · GitHub


上記修正と同じことを既存のSceneクラスに取り込めば、大体は解決しました。

 

8. いくつかのクラス、関数が引数にnullptrを受け付けなくなっていた

CCLuaStack::luaLoadBuffer、CCProgresTimer::initWithSpriteなどです。大抵は呼び出しもとでnullptrハンドリングして終了です。

9. setColorを使ったグレーアウトが動作しなくなっていた

setColorの内容を子供に伝播させるにはNodeのcascadeColorEnabledをtrueにしなくてはならないのですが、様々なCocos2d-x関連クラスのデフォルト値がfalseに変更されました。そのため、setColorを使ったグレーアウトなどの処理が途端に動かなくなったため、急ぎ様々なViewクラスでsetCascadeColorEnabledを呼び出して止血しました。現象次第はシンプルなのですが、如何せん影響範囲が広く、一つの原因に紐づくバグチケットの数がかなり多かったですね。。

10. Androidで一部通信のstyleがunknown_formatになる

 Cocos2dxはAndroidでHttp通信にcurlではなくjava.net.HttpConnectionを使うようになりました。この時、デフォルトヘッダの挙動が変わり Accpet: */* が追加されなくなりました。この時、サーバーサイドのAPIのroutesでレスポンスのMIMEタイプが指定されてないときの挙動の違いにより、レスポンスの解釈がiOSとAndroidで変化するようになってしまいました。というか、基本的にはjson以外受け付けてないんですけどね...

これはアプリ側のリクエストクラスでデフォルトヘッダの差を吸収するようにしています。サーバーサイドでも必ず全てのAPIのroutesで{format: json}を指定するようにしてもらいました。

11. Android4.4未満で通信がバグる

お気の毒ですがCocos2d-x 3.17では4.4未満をサポートしなくなりました

13. ImageViewのアルファチャンネルが無効になるバグ

これはシンプルにcocos2d-xのバグで、ImageViewを2回同じファイル名でloadTexuteを呼び出すとアルファチャンネルが効かなくなるバグです。
これは担当エンジニアがすでに本家にプルリクを送っており、マージされました。
github.com

14. Labelの末尾の改行が無視されなくなった

バグだったのかはわかりませんが、末尾の改行がちゃんと適用されるようになり、それによりレイアウト崩れが発生していました。

15. 9sliceを有効にしたImageViewが特定条件下でバグる

fix_bottomとfix_topが実際の画像の縦幅を超えるというデザインバグがあり、なぜか今まで動いていた状態だったが、バージョンアップによって普通に表示が崩れるようになってしまいました。

16. SceneをaddChildしているところが動かなくなった

そんなことをしてはいけません。おそらくVisittingCameraとかそういうアクティブなシーンのカメラみたいな概念が追加された影響で動かなくなったのかと。replaceSceneやpushSceneを使いましょう。

17. 勝手にボタンがグレーアウトする

ui::Button::setEnableにfalseを渡すと、Buttonクラスの内部で勝手にグレーアウトするようになりました。仕様ということにしました。

18. ScrollViewでクリッピングがバグる

Cocos2d-xのバグです。社内で独自のパッチが当てられましたが、本家では別の形で修正されているようでしたので、そちらの方のリンクを貼っておきます。
https://github.com/cocos2d/cocos2d-x/pull/20352

19. LabelLetterを使っているところでバグる

Cocos2d-x側のバグです。本家にPRを送りました。
Fix: LabelLetter::isVisible always returns false by Riyaaaaa · Pull Request #18975 · cocos2d/cocos2d-x · GitHub

20. Labelの返すgetContentSize().hegihtが半分になっている

これはかなーーり大変な挙動の変化でした。半分になった、というよりはもともとバグっていて2倍になっていたのが元に戻った、というのが正しいです。
しかし、これを前提として構築されたレイアウトがとんでもなく多く、ハードコーディングされたオフセット、italicでアフィン変換された時の位置、様々な箇所に止血対応が入りました。heightサイズバグを再現するLegacyLabelクラスの爆誕などもしました...。この挙動の変化の影響で、とんでもない数の画面の表示がおかしくなっていたのです。。。
全体の工数の10%くらいは、この不具合の対応に追われていたような気もします。笑
 

21. Cocos2d-xのLuaBindingの強化

3.17では、任意のCocos2d-x APIをLuaから呼び出せるように、大規模な改修がされています。ただし、その影響でエンジンのいたるところに、ScriptBindingが有効な時にのみ走る処理が追加されています。弊プロダクトでは、Cocos2d-x Lua-bindingを使用していません。これらの処理は無用なオーバーヘッドなので、CC_ENABLE_SCRIPT_BINDINGを無効にしました。*5


こんなところでしょうか。数が多すぎて網羅できた気がしない...。

おわりに

Cocos2d-xバージョンアップの歴史を一気に振り返りました。この記事を執筆する上で、大量のバグチケットやプルリクを漁り、懐かしい気持ちになりながら、当時の慌ただしさを感じていました。どうせエンジンをアップデートするなら、他のシステムも改善したいというエンジニアの要望もあり、同バージョンには多くのシステム改善がされました。

・Android NDKビルドシステムのndk-buildから、CMakeへの移行
・サウンド系システムの大改修
・CocoaPodsの導入
・様々なThird-pirty製ライブラリのアップデート
・その他パフォーマンス改善など

トータルで半年以上の開発期間(検証含む)を要したこのバージョンは、無事Android 64bit対応期限に間に合い、本番環境で深刻な不具合を出すことなくリリースされました。機能追加を含まないバージョンを、半年以上も開発し続けることができるリソースがチームにあったことはとても喜ばしいことです。これが実現できたのは、現場の開発者だけではなく、マネージャー、運用サイドの担当者などの多くの方のご協力があってのことです。

ちなみにOpenGL ESが本格的にiOSでサポートされなくなり、Metal対応が必須になった時、きっとCocos2d-x v4へのバージョンアップが迫られることでしょう...。メジャーバージョンアップには、今回の規模を遥かに超える改修が必要になりそうです、が、このチームなら乗り越えられる気がしています。多分。

ここまで読んでくださりありがとうございました。


 

*1:しかし、この記事を執筆する上で再度調査していると、実際には2017年には告知されており、弊プロダクトは技術に対するアンテナが細かったために急な対応を迫られてしまった説があります。反省ですね。

*2:この時は、まだAndroid 64bit対応の発表はされていませんでした。

*3:iOSライブラリだけは、64bit対応が2014年くらいに実施されていたので対応されていました

*4:もちろん私はこんなコード書きません。

*5:なぜ有効になっていたのかというと、Cocos2d-xとは関係のないオリジナルのLuaEngineのバックエンドに、CCLuaEngineを使用していたためです。本来はtolua++等のピュアなLua Bindingライブラリを使用すれば必要のない設定でした。