2022年1月18日火曜日

iOS版 東方BGM on VGSも配信再開しました

先日配信再開したAndroid版に続き、iOS版の東方BGM on VGSもAppStoreで配信再開しました。

https://apps.apple.com/jp/app/id680248037

実は数日前から既に旧バージョンをそのまま復活させていたのですが、本日からAndroid版と同等のアップデートがされました。

久々のApple審査対応は中々楽しかったです。(Appleによるバイナリリジェクトが1回、メタデータリジェクトが1回、審査待ちの間の自己審査によるセルフリジェクトが7回ぐらい)

機能的には、Android版とほぼ同じですが、モダンUIの見た目が少し異なります。

Android版のモダンUIはマテリアルデザイン(にしたつもり)で作りましたが、iOS版の方は私の個人的な趣向でフラットデザイン & ダークモード固定にしてみました。

デザイン方面はサッパリなので、マテリアル or フラットのどっちが良いのか実のところよく分からないです。(フラットが好きなのは作りやすいため)

しばらくの間(1ヶ月ぐらい?)、色々と手が回らないと思いますが、落ち着いたら月1回程度でも曲追加できたら良いなと思っています。

半分わざと、私自身のお尻に火をつけるため「曲追加をしないと収益が上げにくいマネタリング構造」にしてみたので、今度こそは続けられる...筈。

なお、旧東方VGSの頃はApple審査に結構時間が掛かった関係で、Androidは即アップデート、iOSはある程度まとまってから月1ペースぐらいでアップデートという(管理が面倒な)リリーススケジュールで運営してましたが、最近は審査期間がAndroidもiOSもあまり変わらないので、今後アップデートはAndroid/iOSほぼほぼ同時になる予定です。

基本、審査が通ったら即リリースするので、若干のラグはあるかもしれませんが。


以下、技術寄りのネタです。


Androidでリリース後、(嬉しいことに)旧UIを懐かしむ声が多かったので、旧UIを再現したRETRO UIというものを(かなり難産しながら)作ってみました。もちろん、iOS版でも RETRO UI は実装済みです。

VGSは元から「半エミュレーション」みたいな技術で作っていたので、単一のFragmentViewで簡単に組み込むことが出来ます。

iOS版もAndroid版と同様、GPLv3ライセンスのOSSとしてソースコードを公開しているので、気になる方は覗いてみてください。

https://github.com/suzukiplan/tohovgs4-ios

なお、Android版の開発言語はKotlinですが、iOS版はSwiftではなくObjective-cです。

新規にスマホアプリを開発するならDart(Flutter)、ゲーム系ならC#(というかUnity)というのが今後のトレンドだと思われるので、Swiftを習得する機会を逸してしまった感があります。(Flutterについてはまだメインストリームというよりは比較的攻めているデベロッパしか使ってないかもしれませんが)

それなら東方VGSも今回はDartで作れば良かった訳ですが、Android版を復活させた時点ではiOS版の復活は未定で、最低限 iOS Developer Program の年会費を支払える程度の収益が得られそうなら復活させようと考えていたので、Dart より手早く作れる Kotlin で開発した経緯があります。

GitHubの方を見て頂けると東方BGM on VGSの言語構成比率を見ることができますが、実装の約9割が C/C++ で、OSネイティブ実装(Kotlin, Objective-c)のコード量は大したことがないから、それなら色々と面倒なP/F共通化をするよりも小回りが効くネイティブ言語(Kotlin, Swift)の方がメンテし易いかも...という見立てもあります。(Flutterではないですが実際こういう話しもあるらしいですし)

安定運用フェーズになれば基本的にネイティブコードは殆ど触らず、songlist.jsonの更新とMMLファイルの追加だけになるように設計しているので、ネイティブコードを触る必要があるのはOSのアップデートや依存ライブラリのアップデートなどへの追従ぐらいになる筈で、それならOSネイティブ言語で開発しておいた方がメンテナンスが楽になるのではないかという見立てもあります。

また、現状曲の追加にはアプリのアップデートが必要な形になってますが、曲データの配信をサーバで行い、アプリのアップデート頻度を下げる方策についても並行して検討中です。


以下、現在検討中のサーバサイド(動的な曲配信)についての技術ネタです。


アプリのアプデ作業も中々手間が掛かるので、サーバで曲データを配信するのが良いのは当然です。

そこで、私が tohovgs-cli (GitHub) で曲データ追加の Pull Request をマージすれば、アプリに追加した曲が配信されるような形のコンテンツ配信システムを現在検討中です。

AWSとかを使えばサーバ系が専門外の私でも、そういったサーバの構築自体は簡単にできます。

ただし、下手に構築してしまうとサーバコストが発生してしまい、それで赤字になるとまた続けられなくなってしまいます。変動費が増える分には売上でペイできるので問題無いかもしれませんが、固定費は極限まで0円に漸近させなければなりません。

今のアプリの構造上、tohovgs-cli の GitHub Pages で songlist.json と MML ファイルだけ配信できれば(つまり、静的ホスティングだけできれば)動的な曲配信に対応出来るので、小規模な内は GitHub Pages でサーバ配信がノーコスト対応可能かも...と思ったのですが、どうやらそういった使い方はNGらしいです。

https://docs.github.com/ja/pages/getting-started-with-github-pages/about-github-pages#prohibited-uses

Prohibited uses

GitHub Pages is not intended for or allowed to be used as a free web hosting service to run your online business, e-commerce site, or any other website that is primarily directed at either facilitating commercial transactions or providing commercial software as a service (SaaS). 

GitHub Pagesは、オンラインビジネス、eコマースサイト、その他商取引の促進や商用ソフトウェア(SaaS)の提供を主目的とするウェブサイトを運営するための無料ウェブホスティングサービスとして使用することは意図されていませんし、許可もされていません。 

なので、動的な曲配信という用途で GitHub Pages を使うことはできません。

その他の方法としてはFirebase Hosting を使う方法もありますが、Sparkプラン(無料)だと 10GB/月 が上限(GitHub Pages の 1/10)で、Blazeプラン(有料)だと10GB/月を超えると 1GB あたり $0.15 らしいです。

AWS S3 なら50TBまでは1GBあたり$0.023なので、一見するとAWSの方がお得に見えますが、CDN(CloudFront)を使う場合、クライアントの地域によって値段が変わり、日本なら 1GB あたり$0.114掛かるので合計すると $0.137。最初の10GBまで無料という点を勘案すると、Firebase Hosting の方がお得かもしれません。

GCPもStorage+CDNならAWSよりちょっと安いぐらい。

東方VGSはそもそもデータサイズが小さいから、恐らく無料枠(10GB)だけで余裕で捌けそうなので、Firebase Hostingが一番コスパが良いだろうと思っているところです。

また、Firebase HostingならダイレクトにGitHub actionsと統合できる点が強いので、私のユースケース的にもやはりFirebase Hostingが有力かなと。

2022年1月5日水曜日

東方BGM on VGSを再公開しました(とりあえずAndroidのみ)

暫くアップデートを怠っており、最新のプログラムポリシーに追従できていなかったためGooglePlayから削除されてしまったAndroid版東方BGM on VGS(東方VGS)を色々とアップデートというか、年末休暇でプログラム部分を全部作り直して配信再開しました。

https://play.google.com/store/apps/details?id=com.suzukiplan.TOHOVGS

また、今回のバージョンからAndroid版もOSS化しました。

https://suzukiplan.github.io/tohovgs4-android/

OSSライセンスはGPLv3です(※ライセンスについては後で MIT に変更するかもしれません)。

assets に MML ファイルをぶち込み、songlist.json を書くだけで、驚くほど簡単に同じようなアプリを作ることができます。

VGS音源を使って同じような音楽配信アプリを作りたい会社などありましたら、ライセンスの範囲内でご自由にどうぞ。

最後に東方VGSのアップデートをしたのが2016年2月なので、もう6年もアップデートしていなかったんですね...その間に色々と足回りの準備をしていたこともあり、色々と遅くてスミマセン。

iOSのDeveloper Accountの維持費を支払うことをやめてiOS版が削除され、その後(2018年頃)アップデートをサボり続けていた結果Android版も削除されて現在(2022年1月)に至るので、Android版は恐らく4年ぶりぐらいの復活ではないかと推測しています。

2023年(10周年)より前に復活できて良かったです。

できれば10周年前にiOS版も復活させたいところ...

今回のアップデートでは、UIをマテリアルデザインっぽい凡庸で使いやすいものに変更しつつ、広告を追加してマネタイズするようにしています。

また、Android 8.0以降でバックグラウンド再生を長時間しているとOSから止められる問題についても対策しました。

新曲の追加はまだありませんが、追々追加していくつもりです。

最低限iOSのDeveloper Account維持費(年間1万2,000円ほど)が支払える程度のプロフィットを得ることができそうであれば、iOS版についても同じような形で再開しようと思っています(多分、遅くともGWぐらいまでには判断できる筈)。

それでは、よろしくお願いします。

(以下、駄文です)

折角久しぶりに東方VGSのネタを書くことができたので、軽くこれまでの経緯を振り返ってみます。

東方VGSを最初にリリースした2013年当時、精神的にヤバイ状態になって休職したりといったイベントがあり、その間(ゴールデンウィーク頃)に作ったものです。

元ネタは昔マイコンBASICマガジン(ベーマガ)という雑誌のMMLで書かれた楽曲投稿のコーナーで、それをVGSの波形メモリ音源のMMLでやってみた感じのものです(最初は東方ではなくヨハン・セバスティアン・バッハの曲でやっていたのですが、残念ながらそっちの方はまだ復活できていません)

ゲーム音楽というのは一般的に権利関係がかなり複雑であることに加え、そもそもマネタイズを一切していなかったので企業やJASRACに相手にされないだろうということで、東方Projectなら二次創作ガイドラインの範囲内でセーフだから東方Projectでやってみた...という経緯があります。

当然ですが、東方Projectが好きだという大前提もあります。

ただし、私は音楽に関しては雑食で、クラシックや映画音楽も東方と同じぐらい好きだし、ラブライブとかもμ’sやAquosに関係なく好きです(静岡出身だから若干Aquosを贔屓している程度)。

ラブライブ(スクフェス)は東方VGSとほぼ同じ時期にリリースされたこともあり、その頃から知っているのですが、ラブライブVGSは(やりたいけど)多分やれません。

そんな東方VGSですが、リリース後からジワジワとですが想定以上に好感触で、Android版は最終的にレビュー数6775で平均評価 4.8 / 5.0 という、かなり凄い評価をいただきました。

iOSでは「神々が恋した幻想郷」と「芥川龍之介の河童」を追加した時のアップデートが一番好評で、100件以上のレビューがついて平均 5.0(パーフェクト)だった時はビックリしました。

もちろん、サクラ無しです。

100件以上のレビューがついて平均 5.0 なんて普通はサクラを使わない限りあり得ない評価ですが、サクラを雇おうにも当時マネタイズを一切していなかったので原資が無いですし、そもそも、サクラを使ってレビューを引き上げるメリットも分かりません。

東方VGSでこれだけ多くの人に評価を頂きながら一切マネタイズをしなかった(後述しますがそもそも「できない」と思っていた)のは、当初は精神安定剤のような効果があったので、それで十分かなと思っていたためです。

しかし、その後精神も安定して当時勤めていた会社を辞め、ドワンゴに転職したりして忙しくなり、そうなると今度はマネタイズしていないことが致命傷になって、結果的に続きませんでした。

精神が安定している状態では、精神安定剤は毒にしかなりません。

私はあまりお金を使わない方なのでガッツリ稼ぐ必要もないのですが、最低限iOS Developer Accountの維持費(年間1万2,000円)程度の収益をあげていなかったことが敗因です。

という訳で、今度はちゃんとマネタイズします。

ただし、二次創作ガイドラインの規定で有料アプリはNGなので、マネタイズといっても広告だけです。

参考: https://touhou-project.news/guideline/

ブラウザ上で動くゲームアプリ、スマートフォンでのゲームアプリは無料のもののみとします。アプリ内への広告の導入、広告機能の解除のための課金機能はOKです。

以前、ZUNさんがWeb媒体か何かのインタビュー記事で、「無料はOK、有料はNG」ということを仰られていたので、少なくとも「有料はNG」という点は分かっていたものの、果たして広告を載せて良いのか、私には判断できませんでした。

つまり、正確に言えばマネタイズを「しなかった」のではなく「出来なかった」訳ですが、今はちゃんとガイドラインとして「広告はOK」と明文化されている点が大きいです。

そこで、大きく分類すると次の3種類の広告を導入しました。

  1. リワード広告(楽曲のアンロック時にタイトル単位で)
  2. バナー広告(アプリ上部に常時表示)
  3. インタースティシャル広告(シャッフル再生リスト作成時)

ハイパーカジュアルのようなLTV(Life Time Value≒アンインストされるまでの期間)が短いアプリの場合、広告解除の課金も重要ですが、東方VGSは他のアプリよりもLTVが長いので、広告解除の課金とはあまり相性が良くありません。

LTVが長い場合、単発の広告解除ではなくサブスクリプションとかにせざるを得ないのですが、サブスクリプションだと「有料販売」に該当するのでガイドライン的にNGになると私は解釈しています。

以前一度、バナー広告を実装したバージョンをAndroidで(本家アプリとは別アプリとして)試験的にリリースしたことがあるのですが、100万DLされているような大御所アプリと違ってニッチ狙いのアプリ(本家でもAndroid+iOS合計して30万DLぐらい)だから、バナーだけだと収益性の面で全然足りなかったので、今回は広告のバリエーションを少し工夫してみました。

一番の要はリワード広告だと思っています。

収益性の状況次第では緩和すること(例えばリワード広告のみに絞るなど)もあり得るかもしれません。

追加楽曲はデフォルトでロックされた状態になっていて、これにより、曲を追加したら追加に掛かった労力に対する十分な(路上ライブの投げ銭程度の)対価が得られるんじゃないか...と淡い期待を抱いていて、それなら続けられるのではないかというのが現在の私の見立てです。

当然ですが、うっかり想定以上に儲かってしまった場合、東方Projectの事務局の方に「これ大丈夫ですか」と確認してみて按分等が必要なら対応するつもりですが、それもシッカリやろうとすると中々面倒なので「ほどほど」が良いです。

どれぐらいが「ほどほど」なのかは人それぞれですが、私の感覚ではギリギリ雑所得以内(年間20万円以内)ぐらいが一番「ほどほど」に同人活動として続けられると思っていて、それを超えたら「同人」ではなく「商人」だと思っています。

仮に、広告のeCPM(1,000回広告を出した当たりの収益)を¥1,000、DAU(1日あたりのアクティブユーザ)1人あたりの平均インプレッションを1と仮定すると、アクティブユーザ1人あたり1円の収益になるので、想定月収は次のようになります。

※Googleとの契約で細かいeCPMとかを公表することはできないので、eCPM ¥1,000というのは計算しやすくするための仮の値です

  • DAU 平均100人 = 月収3,000円
  • DAU 平均500人 = 月収1万5,000円
  • DAU 平均2,500人 = 月収7万5,000円
  • DAU 平均30,000人 = 月収90万円

悲観的な予測として、DAU 100 以下のレンジになるんじゃないかと推定していますが、それでも年間換算するとiOS Developer Programの維持費は賄えるのでセーフです。

しかし、YAU 12,000 ≒ DAU 約33 以下だと、続けることが厳しくなります。

そして、DAU 500 が同人としてほどほどに良い感じのレンジ(目標レンジ)です。

参考値で出した 2,500 という数字は、現在のAndroid版東方VGSのインストール端末数ですが、もう長いことアップデートしていないのにまだこんなにインストールしてくれている端末が残っているということは、もしかするとそれぐらいのDAUになるのかもという楽観的な期待数値です。

そして、30,000 という数字はピーク時の推定DAUで、この辺は完全にお花畑な期待値です。

※当時はFirebaseすら入れてなかったので推定値でしか分かりません...

毎日 30,000 ではなく、曲を追加(アップデート)したタイミングだけが特別多くて、平常時は 3,000 〜 5,000 前後だったのではないかと予測していて、仮に当時広告でマネタイズしてたら、月収換算すると22万円ぐらいは安定して稼いでいたんじゃないかと思われます。

ただ、流石にそれぐらいの水準で稼いでしまったら同人と呼ぶには無理があるので、事務局の方に相談して場合によっては何かしらの対応が必要になり、仮に按分が 7:3 なら月収15万4000円ぐらいという想定です。

その場合、年収換算で184万8000円ですが、そこから国民保険で月約2万(年間約24万)と所得税18万4800円(※専業の場合)が引かれるので、仮に当時専業にしていた場合の手取り年収は142万円ぐらいということになります。

アプリを作り始めた当時は、もっと人生が狂うレベルのお金が稼げるお花畑みたいな予測をしていた時期もありましたが、流石に今となってはそれは無いです。

ゴールドラッシュはもうとっくに終わっているので、見立ては悲観値あたりが現実的な着地点だろうと予測しています。

人が狂うレベルで儲かった時、お金に縁がないけど困ったこともない程度の凡庸な私が果たして狂わずに正気を保てるのかという点に興味はありますが、一度狂ったことがある経験上、狂わずに健康でいることが一番です。

2021年9月29日水曜日

User-AgentでiPadとMacの区別は不可能

例えば、あるURLにアクセスした時に、

  • iOS → AppStoreのアプリページ
  • Android → GooglePlayのアプリページ
  • PC → ランディングページ

という形で機種に応じて異なるURLにリダイレクトする場合、PHPであれば、

<?php

// 言語判定

$isJa = false;

$accept_language = explode(',', $_SERVER['HTTP_ACCEPT_LANGUAGE']);

foreach ($accept_language as $language) {

    if (preg_match('/^ja/i', $language)) {

        $isJa = true;

        break;

    }

}

// 端末判定

$ua = $_SERVER['HTTP_USER_AGENT'];

if ((strpos($ua, 'iPhone') !== false) || (strpos($ua, 'iPad') !== false)) {

    if ($isJa) {

        print '<meta http-equiv="refresh" content="0;URL=https://apps.apple.com/jp/app/アプリID">';

    } else {

        print '<meta http-equiv="refresh" content="0;URL=https://apps.apple.com/app/アプリID">';

    }

} elseif ((strpos($ua, 'Android') !== false)) {

    print '<meta http-equiv="refresh" content="0;URL=https://play.google.com/store/apps/details?id=パッケージID">';

} else {

    print '<meta http-equiv="refresh" content="0;URL=ランディングページURL">';

}

?>

という感じで、User-Agent毎に出力する meta タグを変えればOK...というミスは、誰しもが踏むトラップかと思います。

iPhone or iPad だと、ブラウザにPCモードとモバイルモードみたいなものがあり、モバイルモードであれば、iPhoneなら「iPhone」、iPadなら「iPad」がUser-Agentに含まれていますが、PCモードだとMacと同じ(Macintosh; xxxx Mac OS xxxxみたいな形)になります。

また、iPadはあるバージョンからデフォルトがPCモードになっています。

つまり、上記コードだと、iPadからアクセスするとAppStoreではなくランディングページに飛ぶことになります。

Macでアクセスした時、ランディングページではなくAppStoreに飛ばしてしまっても問題無ければ、iPhone or iPad の判定式を次のように変更してあげれば良いかと思います。

if ((strpos($ua, 'iPhone') !== false) || (strpos($ua, 'iPad') !== false) || (strpos($ua, 'Mac') !== false)) {

User-AgentでiPadとMacの区別は不可能です。(そもそも、ブラウザやOSバージョンによってコロコロ変わるので確実性は保証できません)

参考までに、手元にあるMacとiPadのUser-Agentは以下のように(Mac OS Xのバージョン部分以外は完全に一致する形に)なっていました。

MacBook Air 2020 (Intel) + macOS Big Sir 11.6 + Safari の User-Agent

Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15

iPad Pro 3rd + iPadOS 15.0 + Safari (default = Desktop) の User-Agent

Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15


2021年8月21日土曜日

Activity遷移でTransactionTooLargeExceptionが発生する問題

Activityから別のActivityへ遷移した時、Bundleに保存されている情報がスタックされるのですが、Bundleにスタックできる情報量は1MB以下に制限されていて、1MBを超えるとTransactionTooLargeExceptionでアプリがクラッシュします。

ActivityのBundleだけでなく、FragmentのBundleの情報もスタックされる点に注意が必要です。(これだけが言いたかった)

正直なところ、「Activityの再生成メカニズムはまともに機能できていないのではないか?」と思う今日この頃です。再生成メカニズムというのは具体的には、Activityのライフサイクルの解説図面で言うところの「App process killed」のシーケンスです。

「App process killed」のシーケンスが実行されると、例えばシングルトンで保持しているインスタンス類は全てnullになってロストします。つまり、onCreateはその状態で再実行される可能性があるので、onCreateではシングルトンのデータ復旧処理などを記述しておく必要があります。

その設計思想通りに設計されたアプリが果たしてどの程度あるのか。

少なくとも、私は見たことがありません。

例えば、Google謹製のYouTubeアプリですら、初期状態からのリラン(プロセスリスタートと同等)をしています。

iOSにはこのような中途半端な復旧シーケンスは無くて、カーネルにアプリのプロセスが停止させられた場合、プロセスリスタートになります。

要は、以下が本来あるべき姿だと思います。

Activity Life cycleの本来あるべき姿(私見)

カーネルによりプロセスが停止された場合、アプリプロセスはリスタートで良いと思います。

むしろ、カーネルによる停止時にonDestroy実行保証してくれる方が重要だと思います。それが実現されれば、onDestroyでアプリプロセスがリスタートされた時に必要な情報をアプリ自身で記憶しておき、リスタート時にアプリ自身で復旧シーケンスを実行することが可能なので。

もう少し言ってしまうと、下図ぐらいシンプルにしてしまった方が良かったのではないかなと思います。

  • Start/Resume → onForegroundでまとめる
  • Pause/Stop → onBackgroundでまとめる
  • onRestart相当のものは無くても良い(必要ならonForegroundの引数でOK)
Androidのライフサイクルがこれぐらいシンプルだったら、そもそもTransactionTooLargeExceptionや前の記事で書いたNoSuchMethodExceptionなどというトラブルを生み出さなくても良かった筈。

FragmentActivityがonCreateでNoSuchMethodExceptionで落ちる(アプリが起動時に落ちるetc)

Androidの一部機種で、ActivityがonCreateでNoSuchMethodExceptionが発生してクラッシュする謎の不具合に悩まされ続けていました。せめて、再現できる環境が作れれば良いのですが、クラッシュレポートから確認できる機種とOSバージョンを合わせても再現しないから厄介でした。

再現さえしてくれれば確実に修正できるのですが、再現しない以上、クラッシュレポートから想定される原因を推測して、対策バージョンをリリース、その後のレポートの変化を見て推測の精度を上げるというアプローチで時間をかけて対策するしかありません。

最初に結論を書くと、Fragmentの取り扱いに関する問題で、以下2点の対策をしておけば大丈夫でした。

  1. Fragmentの追加(commit)は Activity.onResume 〜 Activity.onPause の間に行う
  2. Fragmentのコンストラクタは必ず引数無しにする

以下、少し長くなりますが、順を追って説明します。

リリース当初は、NoSuchMethodExceptionは発生していなくて、代わりにIllegalStateExceptionが発生していました。これは単純な私の実装ミスです。

onCreateでFragmentを生成してcommitするとIllegalStateExceptionが発生します。

これは、生成したFragmentをcommitしても良いのは、onResume 〜 onPause の間だけという制約があるためです。そして、Activityが起動すると onCreate → onStart → onResume という順序でコールバックされます。つまり、onCreate呼び出しはonResume前だから、onCreateでFragmentをcommitすると必ずIllegalStatusExceptionになる「はず」です。

ですが、50機種近い機種依存テストを実施しても何故か問題が顕在化しなかったので、リリース時にこのバグを積み残してしまった結果、これに起因するクラッシュが結構な数報告されました。

この問題は、原因がハッキリしているので対策は簡単です。

私は自前のBaseActivityに「onResume中でなければ実行できない処理を滞留しておく仕組み」を作り、Fragmentのcommitはその仕組で滞留させておきonResumeで実行される形にしました。

具体的には、BaseActivityに次のようなexecuteWhileResumeというメソッドを追加します:

open class MyBaseActivity : FragmentActivity() {
private val pendingProcedures = MutableList(0) { {} }
private var pausing = true

fun executeWhileResume(procedure: () -> Unit) {
if (pausing) {
pendingProcedures.add(procedure)
} else {
procedure.invoke()
}
}

override fun onResume() {
super.onResume()
pausing = false
while (pendingProcedures.isNotEmpty()) {
pendingProcedures.removeAt(0).invoke()
}
}

override fun onPause() {
pausing = true
super.onPause()
}
}

そして、Fragmentをcommitする処理は、executeWhileResume { 処理 } とすれば、onResume後に処理が実行されるようになり、IllegalStateExceptionが発生することは無くなりました。

しかし、今度はonCreateの延長で NoSuchMethodException が発生する問題がクラッシュレポートに報告されるようになります。(ここからが本題)

NoSuchMethodException は、とあるFragmentの引数無しコンストラクタをgetConstructorしようとして発生しています。どのFragmentのコンストラクタを実行しようとしたのかは、難読化されていて分かりませんが、クラッシュレポートの報告では「p4.c0.<init>」となっていました。

まず、解せないのは、onCreateの延長でFragmentのコンストラクタが呼び出されている点です。

というのもFragmentの生成→commitは全てexecuteWhileResumeで実行しているので、onCreateからFragmentを生成する筈がありません。もちろん、xml内に埋め込んでいるFragmentも無いので、何故onCreateでこのバグが報告されたのか意味不明でした。

Stack Overflowに似たような報告があります。

https://stackoverflow.com/questions/56668934/java-lang-nosuchmethodexception-for-oncreate

最も支持されている回答は、

The activity is being restored from an instance state bundle. Part of the restore operation is recreating its fragments. (訳: アクティビティは、インスタンス状態のバンドルからリストアされています。リストア操作の一部として、そのフラグメントを再作成しています。)

Your activity has a fragment and the fragment class does not have a 0-arg constructor required by the framework. (訳: アクティビティにフラグメントがあり、フラグメントクラスにフレームワークで必要とされる0argのコンストラクタがありません。)

とのこと。

ただ、先述の通りonCreateではFragmentの生成をしていないので、当初この回答を理解することはできませんでした。(※結果的にはこの回答が正しかったですが...)

別のクラッシュレポートを確認したところ、何故か、onCreateでPreferenceに値をセットしているところでもNoSuchMethodExceptionが発生するレポートも挙がっていました。

これには混乱しました。

完全に意味不明です。

何かしらのI/Oが発生すると、外的にFragmentが生成されるのではないか」という仮設が浮上してしまう始末の悪さ。

そこで、実装としてはかなりキモチワルイのですが、Activityの初期化シーケンスを次のように修正してみることにしました:

  • onCreate では setContentView、findViewById 以外を呼び出さない
  • 従来 onCreate で実行していた初期化処理全般は onResume で自前の初期化処理を once call (一度だけ呼び出す)

これで治ったら逆に意味不明です。

案の定、この対策ではレポート報告は収束しませんでした。

新たなクラッシュレポートは更に意味不明な内容で、Activity の onCreate で自前のログ記録ユーティリティでデバッグログ出力メソッド(リリースビルド時は無出力)をコールしていたのですが、その延長でNoSuchMethodExceptionが発生していました。なにゆえ???

そのデバッグログ出力メソッドは、デバッグビルドであるかを判定してLog.dでログを出力するだけの単純なものです。そして、当然リリースビルドなのでLog.dによるログ出力すらしていない筈です。つまり、何も処理を実行していないメソッド呼び出しの延長で、arg0が無い謎のFragmentのコンストラクタ(p4.c0.<init>)が実行されようとしたのです。

当初、p4.c0.<init> は難読化されたクラス名かと思いましたが、実はAndroidのソースコードでは内部的に https://p4.org と呼ばれる通信制御ライブラリが使われているので、もしかしてそのP4なのだろうか?????

参考までに以下、p4公式サイトの説明を邦訳:

P4(Programming Protocol-Independent Packet Processors)とは、ネットワーク機器のドメイン固有の言語であり、データプレーン機器(スイッチ、NIC、ルーター、フィルターなど)がどのようにパケットを処理するかを規定したものです。

P4以前は、ベンダーがネットワークでサポートされる機能を完全にコントロールしていました。また、ネットワークシリコンが可能な動作の多くを決定するため、シリコンベンダーが新機能(VXLANなど)のロールアウトをコントロールし、ロールアウトには何年もかかっていました。

P4はこのような従来のモデルを覆します。アプリケーション開発者やネットワークエンジニアは、P4を使ってネットワークに特定の動作を実装することができ、変更も数年ではなく数分で行うことができます。

出力ログを収集する怪しいウィルスでもあるのだろうか?と陰謀論のようなことを考えてしまいました。

とりあえずonCreateでのログ出力メソッドの呼び出しは(Activityのライフサイクル検証のため)デバッグ用に入れた冗長なものなので消しておくことにしました。

もう1点、実装当初、Fragmentの再生成を抑止するために、コチラの情報を参考にして、onCreateで super.onCreate(savedInstanceState?.apply { remove("android:support:fragments") }) を実行するようにしていたのですが、コレが問題を引き起こす原因になっている可能性も考えられるかも...とも思うようになりました。というのも、再生成を抑止するために「super.onCreate(null)」を呼び出すと「深刻な問題」を引き起こす可能性があるとコチラに書かれていたので、このロジック的に理解できない呼び出しが影響して、今の意味不明な状態が起きているのではないかと推測しました。

実のところ、この処置(Fragmentの再生性抑止)は、当初のIllegalStateExceptionで血迷って入れてしまったコードなので、executeWhilteResumeの仕組みを導入した時点で削除すべきでした。

という訳で、

  • BaseActivityのログ出力処理を削除
  • super.onCreate(savedInstanceState?.apply { remove("android:support:fragments") }) → super.onCreate(savedInstanceState) に変更 

という修正を入れて、「これで治ってくれ!」と願いながらリリース。

翌日、願うようにCrashlyticsコンソール(この対策の少し前に導入)を覗いてみたのですが、残念ながらまだNoSuchMethodExceptionの報告は収束していません。

ただ、NoSuchMethodException が発生する状況は変わらないのですが、p4.c0.<init> ではなく r4.c0.<init> が呼び出される形になったので、(当然ですが)やはりP4は関係無く、単に難読化で変更されたクラス名だったようです。

難読化、正直居るん? と思いつつあります。

Crashlyticsコンソールで問題発生時のコンディションを詳しく確認した結果、何故onCreateで何らかのFragmentのarg0コンストラクタが実行されたのかを理解することができました。

自分のActivityのonCreateから、NoSuchMethodExceptionが発生するまでのスタックトレースを見てみると、androidx.fragment.app.FragmentController.dispatchCreateの延長でr4.c0.<init>がコールされていることが分かりスッキリ。

つまり、FragmentControllerの仕組みでActivityが再生成されれば現象が発生するようです。そして、起動すると即落ちする現象が発生するユーザ環境では、何故かActivity生成時にこの再生成シーケンスが呼び出されたのであろうことが理解できました。(この情報は、Developer Consoleのクラッシュレポートでは分からず、Crashlyticsを導入して初めて把握できるようになります)

Fragmentサブクラスに引数無しコンストラクタを定義してもAndroid Studioが一切警告してくれなかった(昔はちゃんと警告してくれていた)ので、最新のTarget SDKならその問題が治ったのかと勘違いしてしまい、引数付きのFragmentコンストラクタを大量に作ってしまっていたので、これがFragmentControllerの仕組みとミスマッチだったようです。

という訳で、

  • 自前Fragmentはすべて引数無しに変更
  • 自前Fragmentは例外なく companion object { fun newInstance(... なりでインスタンス化する仕様に変更

という対処をしてリリース。

なお、どういった条件でActivityの再生性が発生するのかは依然として分かりませんが、一度OSホーム画面に戻り、メモリ所要量の多いアプリを複数個起動してからアプリに復帰すれば、Activityのライフサイクル(App process killed)に従ってActivityの再生成が行われるので、その方式でチェック。

そして、「今度こそは治ってくれ...」と切実に願いながらリリース。

無事、NoSuchMethodExceptionのクラッシュレポートは収束したようです!

ただし、今度はまた別の問題(TransactionTooLargeException)がレポートされてきたので、まだしばらくは休めそうにないですが。


2021年8月3日火曜日

Android: EditTextのバックスペース(削除)キーが効かない問題

かなり久々にAndroidのプログラミングネタです。

EditText のバックスペースキー(KeyEvent.KEYCODE_DEL)が一部端末で効かない問題があり、原因を調べてみました。

調査のため、EditText::setOnKeyListener を仕掛けてみたのですが、そもそもバックスペースキーのイベントが飛んできていないらしい。

そこで、試しに Android 9(P) 以降で追加された addOnUnhandledKeyEventListener という、その名の通りハンドルされなかったキーイベントを検出するリスナを仕掛けてみたところ、反応がありました。

という訳で、EditText::addOnUnhandledKeyEventListener がファイアしたらdispatchKeyEventをするという雑な対処を入れてみたところ、正常に動作するようになりました。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
edit.addOnUnhandledKeyEventListener { v, event ->
v.dispatchKeyEvent(event)
true
}
}

ただし、1枚のFragment / Activityに複数のEditTextがある場合、必ずしも編集中のEditTextのOnUnhandledKeyEventListener がファイアされる訳ではないようです。どうも固定のプライオリティ順序でイベントがファイアする(まるでバグのような)仕様のようです。

という訳で、複数のEditTextがある場合、hasFocusでフォーカスを持っているEditTextに対してdispatchKeyEventをしてあげるというクソのような対処が必要になるようです。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val listener = View.OnUnhandledKeyEventListener { v, event ->
when {
edit1.hasFocus() -> {
edit1.dispatchKeyEvent(event)
true
}
edit2.hasFocus() -> {
edit2.dispatchKeyEvent(event)
true
}
else -> {
false
}
}
}
edit1.addOnUnhandledKeyEventListener(listener)
edit2.addOnUnhandledKeyEventListener(listener)
}

そもそも、何故バックスペースキーのイベント(カーソルキーのイベントなども同様)がunhandledになってしまったのかが少し気になるところなので、View::addOnUnhandledKeyEventListenerのドキュメンを確認してみましたが、有益な情報は記述されていないようです。たぶん、Pieで実装された何かしらのOSのエンハンスに引っ張られてunhandledなイベントをアプリ側で捕捉しなければならない何らかのアレが起こったのであろう...と想像。


2021年6月6日日曜日

覇邪の封印(MSX版)の攻略手順

MSX版「覇邪の封印」の攻略情報を書きます。

MSX版には、パッケージに布製の地図とフィギュアが同梱されていますが、これらは単なるオマケではなく、ゲームをプレイするために必要なツールでして、説明書でもフィギュアの左足部分を現在位置に置いてプレイする旨が指示されています。実際に地図の上にフィギュアを置いてプレイしなければ開始直後(トウメガネやセンリノタマ入手まで)で詰みます。

という訳でマップ情報を掲載
スタート地点は左下の「アルカス城」です。

以下、チャートを記しますが、初見プレイなら見ないことを推奨。
(1) 初期地点の城(アルカス城)へ行く
→ コサーマへ会えと言われる
(2) 東2→北7→東1 で「ガリアの街」へ(入らなくてOK)
(3) 東3→北3→東2→北1→東5→北4→東3→北3 で「オルコの街」へ
※この間に「悪の商人」(ハゲてる方の商人)を1体以上狩れることを祈る
※「悪の商人」は1体につき2,000G & 知名度up
(4) オルコの街で「トウメガネ」購入(10,000G)
※以下、地図を見ながら移動
(5) 知名度+150以上になったら「エラトス城」へ行きライセンスをもらう
→ 魔獣を倒した時にキバ(換金アイテム)を獲得できるようになる
(6) エラトス城の東の島に居るコサーマに合う
→ フルキカブトを入手
→ 長老に500G払って使い方を確認
※魔具は基本的に長老に聞いて初めて使えるようになる
(7) 50,000G 溜めて鍛冶屋を雇う
※ここまでで恐らく初期装備は壊れているハズ
※全裸&素手で殴れば大丈夫なので初期装備は修理不要
→ 鍛冶屋を雇ったら タテ と ヨロイ を買う
※雀の涙程度だが防御力が上がり狩り効率がよくなる
(8) キバ300本貯まったらアレオイア城へ城へ行って武器を入手
→ イリスノセンプ(主人公用)を入手
→ 他の仲間の武器は今貰っても捨てるしかないのでもらわない
※主な狩場 = エラテアから一歩西に出たところのローバル(1体キバ4本)
※固定エンカなので効率よく稼げる
(9) 50,000G 溜まったらアルゴノフネを買う
※メノス or エラテアの市場で売っている
(10) 30,000G 溜まったらレッチノツエを買う
※まれにランダムエンカウントの魔道士からタダで貰える
(11) 30,000G 溜まったらセンリノタマを買う
※メノス or エラテアの市場で売っている
※マップの見える範囲が広くなる(無くても問題無い)
(12) 知名度が+6000を超えたら「シロノマキュウ」を入手
※500G払って使い方も聞いておくこと
(13) ウンムタクを狩って「イカリノカガミ」を入手
※500G払って使い方も聞いておくこと
※倒すと知名度が-1,000なので注意
※オルコの街の北(北東)側の平原でエンカウント
(14) 96,000G 貯まったら鍛冶屋の村でイリスノセンプを強化
(15) ローバル狩りである程度レベルを上げる
※この時点ではレベルMAXまで上げなくても良い
※魔獣の洞くつで防具を入手した後の知名度回復作業(24)でレベルMAXにする
※普通にプレイしても、この段階でまだ全然レベルMAXになっていない筈
※攻撃力のバーの上の青いバーが動かなくなればレベルMAX
(16) ブローニュの石碑へ行き呪文(ガリョウイザタタン)を覚える
(17) ガディアの街の市場で仲間(ガイ)を加入
(18) エラトス城へキバ300本を持っていきガイの武器入手
(19) アベイデア城付近の魔獣の洞くつ攻略して(クロノスノヨロイ→主人公)
(20) ドーリスの街の酒場で仲間(メディア)を加入
(21) アベイデア城へキバ300本を持っていきメディアの武器入手 
(22) 残りの魔獣の洞くつをすべて攻略(全員のヨロイとタテが揃う)
※盾の装備順を間違えるとトレモスが仲間に加わわらないので注意
※間違った盾を装備してしまった場合は持ち替えればOK
・オデッセ → 主人公
・ヘクトール → ガイ
・セイレーン → メディア
(23) 聖騎士狩りをして「コウモリノカメン」を入手しておく
※魔獣の洞くつ攻略で知名度が大幅にダウンして-10,000でカンストしている
※聖騎士は1体倒すと-9,999知名度が下がるがカンストしていれば問題無い
(24) 知名度回復作業
※ローバルを400体狩って知名度を+1以上にしておく
※この時点でレベルMAXになるのが理想
(25) ケーペウス神殿で仲間(トレモス)を加入
(26) 石碑で「トレモスキタレリ」を唱えてトレモスの武器と防具を揃える
(27) 鍛冶屋でトレモスの武器を強化
※資金に余裕があればガイ(更に余裕があればメディア)の武器も強化
(28) コサーマに再開
→ 呪文「キタレイアソン」を覚える
(29) イアソンの記念碑で呪文を唱えて3つの鍵を入手
(30) 地下神殿へ行きテラリン撃破
※地下神殿の鍵を挿す順序:
・天の鍵 = ウエ
・地の鍵 = ミギ
・冥の鍵 = ヒダリ
→ テラリン以外のエンカウントはすべて「逃げる」こと
※鍛冶屋無効(武器の耐久度が下がったら回復できない)
※呪い師無効(ココで使えないとなると加える意味がほぼ無い)
呪い師を雇う最適なタイミングは (28) の後
→ 最初にアイテム(イカリノカガミ、シロノマキュウ、レッチノツエ)を使う
→ 次に肉壁たち(トレモス、メディア、ガイ)に死ぬまで殴らせる
→ 最後に主人公が殴ればギリギリ倒せる
※復活の玉があれば余裕をもって倒せる(復活の玉込みならレベリング作業時間をかなり短縮可能な見込みだが、どの程度短縮するかは要調査)

合理的ではないものを作りたい

ここ最近、実機版の東方VGSの開発が忙しくて、東方VGSの曲追加が滞っています。 東方VGS(実機版)のデザインを作りながら検討中。基本レトロUIベースですがシークバーはモダンに倣おうかな…とか pic.twitter.com/YOYprlDsYD — SUZUKI PLAN (...