Android の壁紙アプリで v2.0.0 を公開したあと、Crashlytics のクラッシュ件数が下がらない時期がありました。ビルドは一度も赤くなっていません。アップロードも審査も通っています。それでも Android 6.0.1 の端末では、起動直後に落ちていました。
原因は Java 8 の API を参照しているのにデシュガリングが効いていなかったことで、修正自体は Gradle への1行追加で終わりました。けれど私自身にとって効いたのは、その1行ではありません。「ビルドが通ること」と「出荷できること」の間には距離がある、と手触りで理解したことのほうでした。
Rork のようにクラウド側で生成とビルドの反復が回る構成では、この距離がさらに開きます。エージェントが見ているのはコンパイラとリンカの返事だけで、その先の配信・端末・審査までは視界に入らないからです。
ビルドの成否が保証している範囲
ビルドが緑になるという事実が保証しているのは、おおよそ次の3点に限られます。
参照している型とシンボルが、コンパイル時のクラスパス上に存在すること
リソースの参照が解決でき、リソーステーブルを生成できること
成果物が形式として梱包できること
裏を返せば、ここに含まれないものは何も保証されていません。実行時に別のクラスローダから読まれるもの、配信時に成果物が分割されるときの挙動、症状を後から読み解くための情報、そして成果物に混ざってはいけないもの。私が過去に踏んだ不具合は、きれいにこの4種類へ収まりました。
検査 表面化する場所 ビルドから見えるか
後方互換の欠落 古い OS の実機 見えない(実行時)
密度バケットの偏り 配信後の一部端末 見えない(分割後)
症状を読む情報の欠落 クラッシュ集計の画面 見えない(別経路)
同梱してはいけない資産 公開後の成果物 見えない(意図どおり梱包される)
検査1: 後方互換は設定ではなく成果物で確かめる
デシュガリングの設定は、書いてあるかどうかを目で確かめれば済むように思えます。私も当初はそう扱っていました。実際に困ったのは、設定は正しく見えるのに、依存ライブラリ側が別経路で新しい API 参照を持ち込んでいたときです。ここで学んだのは、判断材料になるのはビルド定義ではなく、出来上がった成果物のほうだという点でした。
成果物の DEX には、参照しているクラス名が文字列としてそのまま並びます。デシュガリングが効いていれば Lj$/util/function/Supplier; のような置き換え後の名前が現れ、効いていなければ Ljava/util/function/Supplier; だけが残ります。ZIP として開いてバイト列を走査すれば、専用ツールなしで判定できます。
個別の症状と修正手順は古い OS バージョンだけクラッシュするときの Java 8 デシュガリング対応 にまとめてあります。ここで足したいのは、直したあとに再発を検出する側の仕組みです。
検査2: 配信の形が変わったときに消えるリソース
App Bundle で配信すると、成果物は端末ごとに分割されて届きます。ある画像が drawable-xxhdpi/ にしか置かれていない場合、その密度に該当しない端末には、その画像が入っていない分割版が配られます。手元の実機では問題なく表示され、レビュー端末でも見えて、公開後に一部のユーザーだけ画面が欠ける、という順序で表面化します。
対処として直感的なのは「全密度を用意する」ほうですが、私が採ったのは逆で、密度を持たせないほうでした。drawable-nodpi/ に置いたリソースは分割の対象から外れ、どの端末にも同じものが届きます。壁紙アプリのように画像そのものが主役の場合、そもそも密度別に持つ理由が薄いという事情もありました。詳しい経緯は密度分割で画像が消えたときの drawable-nodpi 対策 に書いています。
検査としては、成果物内の res/drawable-*dpi/ を走査し、同じファイル名が1つの密度にしか存在しないものを列挙すれば十分でした。
検査3: 症状が読めなくなる欠落
3つ目は、壊れていること自体ではなく、壊れたときに読めないことが問題になる型です。
Hermes のバイトコードとして梱包されたバンドルは、スタックトレースがそのままでは読めません。対応するソースマップと識別子を集計側へ通しておかないと、本番のクラッシュは意味を持たない行番号の羅列として届きます。私は一度、これを整えないまま数週間分のクラッシュを溜めてしまい、後から遡って原因を特定する作業に余計な時間を使いました。手順はHermes のソースマップと debug ID を集計側へ通す方法 に整理しています。
成果物側で確かめられるのは、バンドルが期待どおり Hermes バイトコードになっているかどうかです。設定の行き違いでプレーンな JavaScript のまま梱包されていると、用意したソースマップとは噛み合いません。先頭8バイトのマジックナンバーを見るだけで判別できます。
検査4: 成果物に混ざってはいけないもの
4つ目は、ビルドから見れば完全に正常な動作です。指定されたファイルを、指定されたとおりに梱包しただけだからです。
私が実際に警戒しているのは次の3種類でした。ソースマップの同梱、環境変数ファイルの取り込み、そして署名や API キーに類するファイルの混入です。テンプレートから生成したプロジェクトほど、アセットディレクトリの扱いが緩く、意図しないものが一緒に入りやすい印象を持っています。AdMob のメディエーションアダプタを追加したときのように、依存が増えるタイミングは特に確かめる価値がありました。
これはファイル名のパターンで機械的に拾えます。判断に迷う余地がない分、自動化の効きが最も良い検査でもありました。
4つを1本のスクリプトにまとめる
4項目とも、成果物を ZIP として開いて中身を走査するだけで判定できます。追加の依存を入れず、標準ライブラリだけで書きました。
#!/usr/bin/env python3
"""成果物(.apk / .aab)を ZIP として開き、
ビルドの成否では分からない4点を検査する。
使い方: python3 preflight_artifact.py app-release.aab --min-sdk 23
終了コード: 0=通過 / 1=要確認あり
"""
import argparse
import collections
import re
import sys
import zipfile
# 1) デシュガリング: java.util.function.* を参照しているのに
# 置き換え後のクラスが無いと、minSdk < 24 の端末で落ちる
DESUGARED_TARGETS = [
b "Ljava/util/function/Supplier;" ,
b "Ljava/util/function/Consumer;" ,
b "Ljava/util/Optional;" ,
b "Ljava/time/Duration;" ,
]
DESUGARED_PREFIX = b "Lj$/"
# 4) 成果物に入っていてはいけないもの
FORBIDDEN_PATTERNS = [
re.compile( r " \. map $ " ), # ソースマップの同梱
re.compile( r " (^ | / ) \. env ( \. | $) " ), # 環境変数ファイル
re.compile( r " \. ( keystore | jks | p8 | p12 | pem )$ " ), # 署名・API キー
]
DENSITY_RE = re.compile( r " ^ res/drawable- ([ a-z ] * dpi )( -v \d + ) ? / (. + )$ " )
KNOWN_DENSITIES = [ "ldpi" , "mdpi" , "hdpi" , "xhdpi" , "xxhdpi" , "xxxhdpi" ]
HERMES_MAGIC = bytes ([ 0x C6 , 0x 1F , 0x BC , 0x 03 , 0x C1 , 0x 03 , 0x 19 , 0x 1F ])
def check_desugaring (zf, min_sdk):
"""DEX の文字列プールを直接走査する。ビルド定義ではなく成果物を見る。"""
dex_names = [n for n in zf.namelist() if re.search( r "classes \d * \. dex $ " , n)]
if not dex_names:
return [ "classes.dex が見つかりません(AAB なら base/dex/ を確認)" ]
raw = b "" .join(zf.read(n) for n in dex_names)
has_desugared = DESUGARED_PREFIX in raw
missing = [t.decode() for t in DESUGARED_TARGETS if t in raw]
if min_sdk >= 24 or not missing:
return []
if has_desugared:
return []
return [
"minSdk %d で %s を参照していますが、"
"置き換え後のクラス(Lj$/…)が成果物にありません"
% (min_sdk, ", " .join(missing))
]
def check_density_coverage (zf):
"""1つの密度にしか無い画像は、配信時に他の端末へ届かない。"""
buckets = collections.defaultdict( set )
for name in zf.namelist():
m = DENSITY_RE .match(name)
if m:
buckets[m.group( 3 )].add(m.group( 1 ))
findings = []
for filename, found in sorted (buckets.items()):
if len (found) == 1 and found & set ( KNOWN_DENSITIES ):
findings.append(
" %s が %s にしかありません"
"(drawable-nodpi/ へ移すか全密度を用意)"
% (filename, next ( iter (found)))
)
return findings
def check_js_bundle (zf):
"""Hermes バイトコードで入っているか。プレーンな JavaScript のままだと
用意したソースマップと噛み合わない。"""
targets = [n for n in zf.namelist() if n.endswith( "index.android.bundle" )]
if not targets:
return [] # ネイティブのみの成果物
findings = []
for name in targets:
head = zf.read(name)[: 8 ]
if head != HERMES_MAGIC :
findings.append(
" %s が Hermes バイトコードではありません(先頭 %s )"
% (name, head.hex())
)
return findings
def check_forbidden (zf):
findings = []
for name in zf.namelist():
for pattern in FORBIDDEN_PATTERNS :
if pattern.search(name):
findings.append( "同梱してはいけないファイル: %s " % name)
break
return findings
def main ():
parser = argparse.ArgumentParser()
parser.add_argument( "artifact" )
parser.add_argument( "--min-sdk" , type = int , default = 24 )
args = parser.parse_args()
with zipfile.ZipFile(args.artifact) as zf:
results = [
( "後方互換(デシュガリング)" , check_desugaring(zf, args.min_sdk)),
( "密度バケットの偏り" , check_density_coverage(zf)),
( "JS バンドルの形式" , check_js_bundle(zf)),
( "同梱禁止ファイル" , check_forbidden(zf)),
]
failed = 0
for label, findings in results:
if findings:
failed += 1
print ( "NG %s " % label)
for line in findings:
print ( " - %s " % line)
else :
print ( "OK %s " % label)
print ( "---" )
print ( "要確認 %d 件 / 検査 %d 件" % (failed, len (results)))
return 1 if failed else 0
if __name__ == "__main__" :
sys.exit(main())
意図的に壊した成果物を通すと、4項目すべてが引っかかります。
$ python3 preflight_artifact.py bad.aab --min-sdk 23
NG 後方互換(デシュガリング)
- minSdk 23 で Ljava/util/function/Supplier;, Ljava/util/Optional; を参照していますが、置き換え後のクラス(Lj$/…)が成果物にありません
NG 密度バケットの偏り
- hero_banner.png が xxhdpi にしかありません(drawable-nodpi/ へ移すか全密度を用意)
NG JS バンドルの形式
- assets/index.android.bundle が Hermes バイトコードではありません(先頭 766172205f5f4255)
NG 同梱禁止ファイル
- 同梱してはいけないファイル: assets/index.android.bundle.map
- 同梱してはいけないファイル: assets/.env.production
---
要確認 4 件 / 検査 4 件
同じ成果物を --min-sdk 24 で通すと、1項目目だけが外れます。デシュガリングの必要性は成果物ではなく配信対象で決まるため、この引数は省略せず、Gradle の値と揃えて渡してください。
生成ループの外にゲートを置くという考え方
Rork Max は、クラウド上の Mac で Xcode を回し、出たエラーをエージェントが読んで直し、また組み直すという反復で成果物を作ります。手元でやっている工程がそのまま自動化された構成で、生成物の水準はこの反復に支えられています。裏返すと、反復の判定基準はコンパイラとリンカが返す成否そのものです。
つまり、緑になった時点でループは目的を果たしています。ここで挙げた4項目は、そのループの外側にしか置けません。反復回数を増やしても、指示の書き方を工夫しても、視界に入らないものは検出されないからです。
私が採っている置き方は単純で、次の順序に固定しています。
生成とビルドの反復は、そのまま任せる(介入して回数を増やす価値は薄いと感じています)
成果物が出た時点で、受け入れ検査を1回だけ通す
通ったものだけを段階公開へ乗せ、Crash-free users 99.7% 以上を確認しながら比率を上げる
検査を提出直前ではなく成果物の受け取り直後に置くのは、直しが必要になったとき、再生成の指示に組み込みやすいためです。提出の前夜に見つけると、判断できる選択肢が「出す」か「延ばす」の二択に減ってしまいます。
次の1回のリリースで試すこと
いま手元にある最新の成果物を1本、このスクリプトに通してみてください。過去に配ったものでも構いません。4項目のうち何が引っかかるかで、自分のプロジェクトがどの型の見落としを抱えやすいかが分かります。
私の場合、6本のアプリを並行して更新している都合で、引っかかる項目はアプリごとに違いました。画像資産の多いものは密度の偏り、依存の多いものは同梱禁止ファイル、といった具合です。全部を一度に整えようとせず、自分のプロジェクトで実際に鳴った項目から順に潰していくほうが続きます。
私自身もまだ手探りの部分が多いのですが、ビルドの緑を信じきらない設計は、個人開発でこそ効くと感じています。お読みいただきありがとうございました。