Android のアプリを開発する際にソースコードに難読化を施すために Proguard を使っていて Serializable
なクラスが難読化対象になっている場合に、思いもよらないバグが発生してしまうことがあります。先日そのバグに遭遇してしまい、原因究明にほぼ丸1日かかってしまいました。
発生事象
- 新機能を追加したアプリをバージョンを1つあげて Google Play ストアに登録した。
- アプリをアップデートしてから起動する。
- 設定画面を開くと設定情報が消えてる。
こんな感じでした。アップデートに含まれる改修では、設定画面はまったくいじってなかったのでなんで突然こんなことが起きるのか謎。ちなみに設定ファイルは
- 設定情報は
Serializable
を implements したクラスをシリアライズしてファイルシステムに書き出してある - そのクラスは Eclipse の機能を使って
serialVersionUID
を自動生成している - 読み込みは
ObjectInputStream
を使ってファイルを読み込んで元のクラスにキャストして使っている
という風にファイルに保存して管理していました。
原因
結論としては Proguard で難読化された結果、マッピング先のクラスが変わっており serialVersionUID
が正しく取得できておらず InvalidClassException
が発生していたのが要因でした。
以下では手順を追って説明していきます。
設定クラスの定義
たとえば Configuration
クラスがファイルの保存・読み込みを行うクラスとして、以下のように実装されています。(当然この他にも各値のゲッターの実装もあるとは思いますが、簡略化のためにここでは省略します)
public class Configuration implements Serializable {
private static final long serialVersionUID = -2203743867770177617L;
private String mName;
private int mAge;
private int mSex;
public LocalTargetProfile(String name, int age, int sex) {
mName = name;
mAge = age;
mSex = sex;
}
}
そして Application
クラスを継承した MainApplication
クラスの中で、この Configuration
クラスの読み書きを行うメソッドが以下のように実装されています。(簡略化のために例外処理やファイナライズ処理は省略します)
public class MainApplication extends Application {
public void saveConfiguration(Configuration configuration) {
FileOutputStream fileStream = openFileOutput(fileName, MODE_PRIVATE);
ObjectOutputStream objectStream = new ObjectOutputStream(fileStream);
objectStream.writeObject(configuration);
}
public Configuration loadConfiguration() {
FileInputStream fileStream = openFileInput(CONFIGURATION_FILE_NAME);
ObjectInputStream objectStream = new ObjectInputStream(fileStream);
Object object = objectStream.readObject();
return (Configuration) object;
}
}
設定画面では、設定の保存時に saveConfiguration
を、設定の読み込み時に loadConfiguration
をそれぞれ呼び出しています。
Proguard を使った難読化
Proguard を使ってソースコードに難読化を施すと
元のクラス | 難読化後のクラス |
---|---|
com.example.hoge.Configuration | com.example.hoge.a |
com.example.hoge.MainApplication | com.example.hoge.b |
com.example.hoge.OtherClasses | com.example.hoge.c |
このように、一見なんのクラスなのかわからないようにクラス名が変換されます。また、クラス名だけでなくフィールドやメソッドの名前も変換されます。ただし、ここでは serialVersionUID
については名前が変わらないように Proguard に以下のような設定をしてあります。(名前が変わるとシリアライズ及びデシリアライズの際に認識されなくなる)
-keepclassmembers class * implements java.io.Serializable {
static final long serialVersionUID;
}
ようするに難読化されるとたぶんこんな感じになります(メソッドの中身の変数とかもたぶん難読化されるとは思うけど、面倒くさいので未確認)。保存や読み込み時に Configuration
クラスじゃなくて a
クラスの受け渡しをしてるのがわかります。
public class a implements Serializable {
private static final long serialVersionUID = -2203743867770177617L;
private String a;
private int b;
private int c;
public a(String name, int age, int sex) {
a = name;
b = age;
c = sex;
}
}
public class b extends Application {
public void a(a configuration) {
FileOutputStream fileStream = openFileOutput(fileName, MODE_PRIVATE);
ObjectOutputStream objectStream = new ObjectOutputStream(fileStream);
objectStream.writeObject(configuration);
}
public a a() {
FileInputStream fileStream = openFileInput(CONFIGURATION_FILE_NAME);
ObjectInputStream objectStream = new ObjectInputStream(fileStream);
Object object = objectStream.readObject();
return (a) object;
}
}
新しいクラスの追加
ここでたとえば AndroidDebugger
というクラスを追加したとします。
public class AndroidDebugger {
// 実装は省略...
}
ここでもう一度 Proguard を使ってビルドすると
元のクラス | 難読化後のクラス |
---|---|
com.example.hoge.AndroidDebugger | com.example.hoge.a |
com.example.hoge.Configuration | com.example.hoge.b |
com.example.hoge.MainApplication | com.example.hoge.c |
com.example.hoge.OtherClasses | com.example.hoge.d |
このようなクラス名のマッピングとなりました。
マッピングの変更
最初にビルドした時は Configuration
クラスは a
クラスにマッピングされていました。これを バージョン1 とします。そして次にビルドすると Configuration
クラスは b
クラスにマッピングされていました。これを バージョン2 とします。そして、たとえば次のような手順を踏んでいたらどうなるでしょうか。
バージョンアップの手順
- バージョン1がインストールされている状態で設定画面から設定ファイルを保存した
- その後バージョン2にアップデートした
- バージョン2で設定画面を開いて設定ファイルを読み込む
その結果…
なんとなく想像はつくと思いますが
- バージョン1では、書き込み時に、
a
クラス (つまりConfiguration
クラス) をファイルに保存します。 - そしてその時の
serialVersionUID
は -2203743867770177617L です。 - バージョン2では、読み込み時に、
a
クラスを読み込みますが、バージョン2ではa
クラスはAndroidDebugger
クラスです。 - そして
AndroidDebugger
クラスにはserialVersionUID
は定義されていません。つまりデフォルト値の 0 になります。
このようにして書き込み時の serialVersionUID
と読み込み時の serialVersionUID
の不一致のせいで InvalidClassException
が発生して設定ファイルを読み込めなくなったため、まるで設定ファイルが消えてしまったかのような挙動になっていました。
対応方法
今回の事象は Proguard の難読化処理により、マッピング先のクラス名が変わってしまったことが要因なので、特定のクラスに対してはマッピングが変わらないように設定を追加することで解決します。
マッピングの維持
Proguard は -applymapping
というオプションを持っており、これを指定することでこちらの意図通りにマッピングを維持することができます。
Proguard で難読化した際に mapping.txt
という成果物ができますが、この中にクラスのマッピング情報が書き込まれています。ただのテキストデータなのでエディタで開いて、この中から Configuration
クラスのマッピング情報を取り出して、たとえば applymapping.txt
としてルートディレクトリに保存します。
com.example.hoge.Configuration -> com.example.hoge.a
long serialVersionUID -> serialVersionUID
String mName -> a
int mAge -> b
int mSex -> c
...
そしてこれを Proguard から読み込むように proguard-project.txt
に以下を追記します。
-applymapping applymapping.txt
これで再度ビルドすると Configuration
クラスは常に a
クラスにマッピングされるようになります。
その後の対応
これでめでたく解決したと思いきや、まだ問題は残っていました。この不具合を修正した バージョン3 をリリースしたとしましょう。この時点での Configuration
クラスのマッピングは以下のようになっています。
バージョン | Configurationクラスのマッピング先 |
---|---|
バージョン1 | com.example.hoge.a |
バージョン2 | com.example.hoge.b |
バージョン3 | com.example.hoge.a |
はい。
バージョン1からバージョン3へアップデートする場合は問題ありませんが、バージョン2からバージョン3へアップデートする場合はまた同じ問題が発生してしまいます。もちろんバージョン2になってまだ一度も設定ファイルを書き込んだことがなければ問題ないのですが、ユーザーがバージョン2の間に設定の保存操作をしてない保証なんてどこにもありません。この問題についてはかなり無理矢理ですが以下の様に対応しました。
b クラスを作る
バージョン2では b
クラスが Configuration
クラスになっているため、設定を保存されたら b
クラスとしてファイルに書きだされることになります。そしてそれを読み出す時に b
クラスが他のクラスにマッピングされていてはいけません。b
クラスが必要なことはもうわかってるので、難読化前にあらかじめ自分で b
クラスを作っておきます。
b
クラスの内容は serialVersionUID
も含めて難読化後の a
クラスと全く同じです。mapping.txt
を参照して難読化後のフィールド名やメソッド名を確認しながら作りました。こんな感じ。
public class b implements Serializable {
private static final long serialVersionUID = -2203743867770177617L;
private String a;
private int b;
private int c;
public b(String name, int age, int sex) {
a = name;
b = age;
c = sex;
}
}
設定ファイル読み込む部分を修正する
設定ファイルを読み込んだ時に b
クラスかどうかをチェックして b
クラスであれば Configuration
クラスに変換するようにしました。これでアプリケーション内では設定ファイルが Configuration
クラスなのか b
クラスなのかを意識せずに、全て Configuration
クラスとして扱うことができます。
public Configuration loadConfiguration() {
FileInputStream fileStream = openFileInput(CONFIGURATION_FILE_NAME);
ObjectInputStream objectStream = new ObjectInputStream(fileStream);
Object object = objectStream.readObject();
+ if (object instanceof b) {
+ object = new Configuration(
+ ((b) object).getName(),
+ ((b) object).getAge(),
+ ((b) object).getSex()
+ );
+ }
return (Configuration) object;
}
b クラスを難読化対象から外す
最後に念の為に b
クラスを難読化の対象とならないようにしておきます。
-keep class com.example.hoge.b { *; }
おわりに
最後の対応方法はかなり無理やりでこれが正しかったのかどうかはわかりませんが、とりあえずちゃんと動いてます。こんなニッチな問題、あんまり遭遇する人いないかもしれないけど、まあこういうの公開しとくとどっかで誰かが助かるかもしれない(それは未来の自分かもしれないね)