Android SDK開発におけるAPI差分の自動抽出と監視の仕組み

はじめに

こんにちは、アドフリくんSDKのAndroidエンジニアを担当している井上です。

「アドフリくん」は、スマートフォンアプリ向けの動画リワード広告やインタースティシャル広告などを提供するSSP(Supply-Side Platform)です。私たちは、アプリ開発者の皆様が簡単に複数のアドネットワークを導入・最適化できるよう、Android/iOS向けのSDKを提供しています。

SDKを開発・提供する上で、公開API(Public API)の管理は非常に重要です。SDKのアップデートによって意図せずAPIが変更・削除されたり、公開すべきでない内部メソッドが公開されてしまうと、SDKを導入しているアプリ開発者に多大な影響を与えてしまいます。

また、私たちのチームではネイティブだけでなく、Unity、Cocos2d-x、Flutter、React Nativeといったクロスプラットフォーム向けのアダプターも提供しています。そのため、ネイティブSDKのAPI変更がアダプターにどのような影響を与えるのかを正確に把握する必要があります。

本記事では、これらの課題を解決するために導入した「API差分の自動抽出と監視の仕組み」について、具体的な実装例を交えて紹介します。なお、今回は私が担当している Android SDK(Java/Kotlin)に限定した解説 となります。

なぜAPIの監視が必要なのか?

SDK開発においてAPIの監視が必要な理由は、主に以下の3点です。

  1. アプリ開発者への影響を最小限にするため
    SDKのバージョンアップ時に破壊的変更が含まれていると、アプリ側のビルドが通らなくなったり、実行時エラーが発生したりします。意図した変更であればリリースノート等で周知できますが、意図しない変更は事故につながります。
  2. 意図しないAPIの公開を防ぐため
    Kotlinの internal 修飾子を付け忘れたり、アクセス修飾子を誤ったりすることで、内部実装用のメソッドが public として公開されてしまうことがあります。これを防ぐための仕組みが必要です。
  3. クロスプラットフォーム向けアダプターへの影響範囲を特定するため
    ネイティブSDKのAPIが変更された場合、それをラップしている各プラットフォーム向けのアダプター(Unity, Flutterなど)も追従して修正する必要があります。APIの変更を検知できれば、アダプター側の修正漏れを防ぐことができます。

また、監視対象はSDKが提供する「API(メソッド)」だけでなく、アプリ側にイベントを通知するための「Callback(リスナーインターフェース)」も含まれます。Callbackのメソッドシグネチャが変更されると、アプリ側で実装しているリスナーが呼ばれなくなるなどの重大な不具合に直結するため、APIと同等に厳密な監視が必要です。

実装アプローチ:javap コマンドの活用

APIの差分を抽出する方法はいくつかありますが、今回はビルド済みの .class ファイルから情報を抽出するアプローチを採用しました。具体的には、JDKに標準で付属している逆アセンブラツールである javap コマンドを使用します。

ソースコードを解析する方法もありますが、javap を使うことで、コンパイラが最終的に生成したバイトコードベースでの「真の公開API」を正確に把握できるというメリットがあります。

具体的な実装スクリプト

CI上で実行するシェルスクリプト(check_api.sh)を作成しました。このスクリプトは、指定されたクラスの public メソッドを抽出し、一覧化します。

以下がそのスクリプトの主要部分です。

#!/bin/bash
set -e
 
# 1. SDKのビルド
# プロジェクトのルートディレクトリとビルド対象のモジュールを指定
PROJECT_ROOT="$(git rev-parse --show-toplevel)/your_sdk_module"
cd "$PROJECT_ROOT"
./gradlew sdk:compileReleaseKotlin
 
# ビルドされたクラスファイルのディレクトリを特定
BUILD_DIR=$(find "$PROJECT_ROOT" -type d -path '*/build/tmp/kotlin-classes/release' | head -n 1)
 
# 2. メソッド抽出関数
get_all_methods_recursive() {
  local sub_class_name="$1"
  local class_file="$2"
  local visited_file="$3"
  local output_file="$4"
  local class_type="$5"
 
  # 訪問済みチェック(無限ループ防止)
  if grep -q "^$(basename "$class_file")$" "$visited_file" 2>/dev/null; then return; fi
  echo "$(basename "$class_file")" >> "$visited_file"
 
  if [ ! -f "$class_file" ]; then return; fi
 
  local current_class_name=$(basename "$class_file" | sed 's/\.class//')
 
  # javapでクラス情報を取得し、publicメソッドのみを抽出
  local javap_output=$(javap -p "$class_file" 2>/dev/null)
  local grep_filter="grep -E '^\s*(public)\s+.*\(.*\)'"
 
  # class_typeが"API"の場合のみ ' abstract ' を除外(Callbackの場合はabstractメソッドも抽出対象とする)
  if [ "$class_type" == "API" ]; then
    grep_filter="$grep_filter | grep -v ' abstract '"
  fi
 
  # 不要なメソッド(内部生成メソッドなど)を除外
  grep_filter="$grep_filter | grep -v ' class '"
  grep_filter="$grep_filter | grep -v '\$sdk_release'"
  grep_filter="$grep_filter | grep -v '\$lambda'"
  grep_filter="$grep_filter | grep -vF 'access\$'"
 
  # 抽出と整形
  echo "$javap_output" | eval "$grep_filter" \
    | sed -E 's/^\s*(public|private|protected|final|static|abstract|synchronized|native)\s*//g' \
    | sed -E 's/^\s*//' \
    | sed 's/abstract //g' \
    | sed 's/static //g' \
    | sed 's/public //g' \
    | sed 's/final //g' \
    | sed 's/synchronized //g' \
    | sed 's/;//g' \
    | sort -u \
    | while read -r line; do
        local method_name=$line
        if [ -n "$method_name" ]; then
          # CSV形式で出力
          printf '"%s","%s","%s","%s"\n' "$sub_class_name" "$current_class_name" "$class_type" "$method_name" >> "$output_file"
        fi
      done
 
  # 3. 親クラスの再帰的な探索
  local super_class=$(javap -p "$class_file" 2>/dev/null \
    | grep -E ' extends | : ' \
    | grep -v '<.*>' \
    | sed -E 's/.*(extends|:) ([^ ]+).*/\2/' \
    | sed 's/\./\//g' \
    | cut -d'<' -f1 )
 
  if [ -n "$super_class" ] && [ "$super_class" != "java/lang/Object" ]; then
    local super_file="$BUILD_DIR/$super_class.class"
    if [ -f "$super_file" ]; then
      get_all_methods_recursive "$sub_class_name" "$super_file" "$visited_file" "$output_file" "$class_type"
    fi
  fi
}
 
# 4. 対象クラスリストの読み込みと実行
TARGET_CLASSES_FILE="path/to/your/class_list.csv"
API_LIST_FILE="api_list.txt"
 
while IFS=, read -r logical_name class_type class_path; do
  # ... (パスの整形処理など) ...
  target_class_file="$BUILD_DIR/$target_class_path"
 
  get_all_methods_recursive "$logical_name" "$target_class_file" "$VISITED_CLASSES" "$API_LIST_FILE" "$class_type"
done < "$TARGET_CLASSES_FILE"
 
# 5. 結果のアップロードと差分比較
# python3 scripts/update_sheet.py "$API_LIST_FILE"

スクリプトのポイント

  1. javap による解析
    javap -p コマンドでクラスのシグネチャを出力し、grep と正規表現を用いて public なメソッドのみを抽出しています。
  2. APIとCallbackの出し分け
    クラス種別(class_type)が API の場合は abstract メソッドを除外し、Callback(インターフェース)の場合は abstract メソッドも含めて抽出するように制御しています。これにより、リスナーインターフェースの変更も漏れなく検知できます。
  3. Kotlin特有のメソッド除外
    Kotlinコンパイラが自動生成する内部メソッド($sdk_release$lambdaaccess$ など)は、公開APIとしては扱わないため除外しています。
  4. 親クラスの再帰探索
    継承元のクラスで定義されている public メソッドも、子クラスのAPIとして公開されるため、javap の出力から extends を探し、親クラスの .class ファイルも再帰的に解析しています。
  5. リスト化と可視化
    抽出した結果はCSV形式で保存し、最終的にPythonスクリプト経由でGoogle Sheetsなどにアップロードします。これにより、過去のバージョンとの差分比較が容易になります。

リファレンスとの差分比較と出力

抽出したAPIリスト(CSV)は、Pythonスクリプト(update_sheet.py)を用いて、正となるリファレンスデータ(Google Sheetsなど)と比較します。

比較にはデータ分析ライブラリの pandas を活用すると、非常にシンプルに実装できます。

import pandas as pd
 
def find_differences(csv_file, reference_data):
    # 今回抽出したAPIリストを読み込み
    df_csv = pd.read_csv(csv_file)
 
    # 比較元(リファレンス)のデータを読み込み(例: Google Sheetsから取得したデータ)
    df_sheet = pd.DataFrame(reference_data)
 
    # 列数を揃えるなどの前処理(省略)
 
    # outer joinでマージし、indicator=Trueでどちらに存在するかを判定
    merged = pd.merge(df_csv, df_sheet, on=['Col_1', 'Col_2', 'Col_3', 'Col_4'], how='outer', indicator=True)
 
    # CSVにのみ存在する行(追加されたAPI)
    added_rows = merged[merged['_merge'] == 'left_only'].drop(columns=['_merge'])
    added_rows['Status'] = 'Added'
 
    # リファレンスにのみ存在する行(削除されたAPI)
    deleted_rows = merged[merged['_merge'] == 'right_only'].drop(columns=['_merge'])
    deleted_rows['Status'] = 'Deleted'
 
    # 差分を結合して返す
    return pd.concat([added_rows, deleted_rows], ignore_index=True)

この比較結果をもとに、以下の2つの形式で出力を行っています。

  1. Google Sheetsへの出力(一覧化)
    差分専用のスプレッドシートに結果を書き込みます。これにより、チーム全体で「どのAPIが追加・削除されたか」を一覧で確認・共有しやすくなります。

  2. Markdownファイルへの出力(PR通知用)
    CIのステップ内で、差分結果をMarkdown形式のテキストファイル(diff_result.md)として出力します。

    ### APIの差分確認結果
     
    Col_1                 Col_2                 Col_3     Col_4                                Status
    MySdkClass            MySdkClass            API       void setNewMethod(java.lang.String)  Added
    MySdkClass            MySdkClassBase        API       void oldMethod()                     Deleted
    MySdkListener         MySdkListener         Callback  void onAdded(int)                    Added
    MySdkListener         MySdkListener         Callback  void onDeleted(java.lang.String)     Deleted

    各カラムの意味は以下の通りです。

    • Col_1 : 調査対象のクラス名(一番の子クラス、または論理名)
    • Col_2 : メソッドが実際に定義されているクラス名(親クラスから継承したメソッドの場合は、その親クラス名が入ります)
    • Col_3 : クラス種別(API または Callback)
    • Col_4 : メソッドのシグネチャ(戻り値、メソッド名、引数の型)

    このMarkdownファイルを、GitHubのPull Requestに自動でコメント投稿する仕組みにしています。

運用フロー

このスクリプト群をCIのパイプラインに組み込み、以下のフローで運用しています。

  1. 開発者がPull Request(PR)を作成する。
  2. CIが走り、SDKをビルドして check_api.sh を実行し、APIリストを抽出する。
  3. update_sheet.py が実行され、リファレンスデータとの差分を比較する。
  4. 差分結果がGoogle Sheetsに保存され、同時にPRのコメントとしてMarkdown形式で通知される。
  5. レビュアーはPRのコメントを見て、意図しないAPIの追加・変更・削除がないか、また各プラットフォーム向けアダプターへの影響がないかを確認する。

まとめ

javap コマンドを活用したシンプルなシェルスクリプトですが、これをCIに組み込むことで、SDKのAPI変更を自動的に検知・可視化できるようになりました。

これにより、意図しない破壊的変更を防ぎ、アプリ開発者に安心して使ってもらえるSDKを提供できるだけでなく、マルチプラットフォーム展開におけるアダプターの保守コストを下げることにも繋がっています。

SDK開発においてAPIの互換性維持に課題を感じている方は、ぜひ参考にしてみてください。