eaglesakuraの技術ブログ

技術的な話題とか、メモとか。

Kotlin Android-Parcelize拡張の後方互換問題を考える

Parcelize拡張概要

  • Androidアプリ開発者にとってメンドウな Parcelable の定形実装を自動化できる
  • Annotationをつけるだけで、フィールドを全部Parcelableに突っ込んでくkれる
    • 突っ込めないオブジェクトは警告が出る
    • Annotationで除外を指定できる

Example

// これだけでParcelableを実装できる
// BundleにもIntentにも突っ込める
@Parcelize
class ExampleUser(
    val name: String
) : Parcelable

何が問題か?

  • 生成されたオブジェクトをアプリ間で受け渡す際に、シリアライズ・デシリアライズの問題が発生する恐れがあるのでは?と考えた
  • フィールドが増減した時、フィールドの記述順が変更になった時、コイツは死ぬのでは?
  • 例えば、自社のアプリ間で通信したい場合とか。

問題のある例

// アプリAではこのバージョンのクラスを使用している
// ライブラリVersion 1.0
@Parcelize
class ExampleUser(
    val name: String
) : Parcelable
// アプリBでは、ちょっと機能が増えた
// ライブラリVersion 1.1
@Parcelize
class ExampleUser(
    val name: String,
    val age: Int // 年齢フィールドを増やしたぞ
) : Parcelable
  • この状態で、アプリAからアプリBにデータを渡すとどうなる?
  • 多分死ぬ

現状を確認する

生成されるClassをjad

  • こんなCreatorが自動生成される
  • in.readString() している
public final class ExampleUser
    implements Parcelable
{
    public static class Creator
        implements android.os.Parcelable.Creator
    {

        public final Object[] newArray(int size)
        {
            return new ExampleUser[size];
        }

        public final Object createFromParcel(Parcel in)
        {
            Intrinsics.checkParameterIsNotNull(in, "in");
            return new ExampleUser(in.readString());
        }

        public Creator()
        {
        }
    }
}

フィールドを増やすとどうなるか?

  • 案の定、順番通りにreadする
  • このタイミングでデータ長が足りずに壊れる
        public final Object createFromParcel(Parcel in)
        {
            Intrinsics.checkParameterIsNotNull(in, "in");
            return new ExampleUser(in.readString(), in.readInt());
        }

順番が変わるとどうなる?

  • 当然、順番が変わる
  • Classは死ぬ
        public final Object createFromParcel(Parcel in)
        {
            Intrinsics.checkParameterIsNotNull(in, "in");
            return new ExampleUser(in.readInt(), in.readString());
        }

どうするか?

  • 外見仕様を変えずに、互換性を何とかする
  • Parcelable互換で、高機能な Bundle とカスタムプロパティでなんとかする
  • bundle.get をすることで、古いデータ仕様の場合はdefaultを詰め込むことができる
@Parcelize
class ExampleUser internal constructor(
    private val bundle: Bundle
) : Parcelable {

    constructor(name: String, age: Int) : this(
        bundleOf(
            Pair("name", name),
            Pair("age", age)
        )
    )

    val age: Int
        get() = bundle.getInt("age", -1)

    val name: String
        get() = bundle.getString("name")!!
}
// jadるとこんな感じになる

    public final int getAge()
    {
        return bundle.getInt("age", -1);
    }

    public final String getName()
    {
        String s = bundle.getString("name");
        s;
        if(s == null)
            Intrinsics.throwNpe();
        return;
    }

    public ExampleUser(Bundle bundle)
    {
        Intrinsics.checkParameterIsNotNull(bundle, "bundle");
        super();
        this.bundle = bundle;
    }

    public ExampleUser(String name, int age)
    {
        Intrinsics.checkParameterIsNotNull(name, "name");
        this(BundleKt.bundleOf(new Pair[] {
            new Pair("name", name), new Pair("age", Integer.valueOf(age))
        }));
    }

Bundle in Bundleすると死ぬ

単純に実装すると、 Bundle.putParcelable() で突っ込んだBundleが死ぬ。 復元時に ClassLoader が読み込まれていないようだ。

なので、次のようにするとClassLoaderを解決できる

@Parcelize
class ExampleUser internal constructor(
    private val bundle: Bundle
) : Parcelable {
    init {
        // class loaderを指定して復元させる
        bundle.classLoader = javaClass.classLoader
    }
}
  • 実装はモノグサしたので、あとは interface なり abstract なりで隠蔽してキレイに見せかけてあげれば手抜きで後方互換もある程度担保できるんじゃないかな