はじめに
こんにちは、アドフリくん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点です。
- アプリ開発者への影響を最小限にするため
SDKのバージョンアップ時に破壊的変更が含まれていると、アプリ側のビルドが通らなくなったり、実行時エラーが発生したりします。意図した変更であればリリースノート等で周知できますが、意図しない変更は事故につながります。 - 意図しないAPIの公開を防ぐため
Kotlinのinternal修飾子を付け忘れたり、アクセス修飾子を誤ったりすることで、内部実装用のメソッドがpublicとして公開されてしまうことがあります。これを防ぐための仕組みが必要です。 - クロスプラットフォーム向けアダプターへの影響範囲を特定するため
ネイティブ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" スクリプトのポイント
-
javapによる解析
javap -pコマンドでクラスのシグネチャを出力し、grepと正規表現を用いてpublicなメソッドのみを抽出しています。 - APIとCallbackの出し分け
クラス種別(class_type)がAPIの場合はabstractメソッドを除外し、Callback(インターフェース)の場合はabstractメソッドも含めて抽出するように制御しています。これにより、リスナーインターフェースの変更も漏れなく検知できます。 - Kotlin特有のメソッド除外
Kotlinコンパイラが自動生成する内部メソッド($sdk_releaseや$lambda、access$など)は、公開APIとしては扱わないため除外しています。 - 親クラスの再帰探索
継承元のクラスで定義されているpublicメソッドも、子クラスのAPIとして公開されるため、javapの出力からextendsを探し、親クラスの.classファイルも再帰的に解析しています。 - リスト化と可視化
抽出した結果は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つの形式で出力を行っています。
-
Google Sheetsへの出力(一覧化)
差分専用のスプレッドシートに結果を書き込みます。これにより、チーム全体で「どのAPIが追加・削除されたか」を一覧で確認・共有しやすくなります。 -
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のパイプラインに組み込み、以下のフローで運用しています。
- 開発者がPull Request(PR)を作成する。
- CIが走り、SDKをビルドして
check_api.shを実行し、APIリストを抽出する。 -
update_sheet.pyが実行され、リファレンスデータとの差分を比較する。 - 差分結果がGoogle Sheetsに保存され、同時にPRのコメントとしてMarkdown形式で通知される。
- レビュアーはPRのコメントを見て、意図しないAPIの追加・変更・削除がないか、また各プラットフォーム向けアダプターへの影響がないかを確認する。
まとめ
javap コマンドを活用したシンプルなシェルスクリプトですが、これをCIに組み込むことで、SDKのAPI変更を自動的に検知・可視化できるようになりました。
これにより、意図しない破壊的変更を防ぎ、アプリ開発者に安心して使ってもらえるSDKを提供できるだけでなく、マルチプラットフォーム展開におけるアダプターの保守コストを下げることにも繋がっています。
SDK開発においてAPIの互換性維持に課題を感じている方は、ぜひ参考にしてみてください。