Androidアプリの後方互換性

android

Android 1.5がリリースされました。開発者ブログにもいろいろと情報が出てきています。Backward compatibility for Android applications という、アプリ開発における後方互換性に関する記事がありました。
以下、メモとしてざっくり抄訳しておきます。

Androidアプリの後方互換性

1.5では新しいAPIがたくさん追加されています。開発者は古いデバイスにおける後方互換性の問題について、知っておかなくてはなりません。
そのアプリをすべてのデバイスで動かしたいのですか?それとも新しいデバイスで動かしたいのですか?

SDKバージョン

もし新しいAPIがアプリに不可欠なものであれば、そのアプリが古いデバイスにインストールされないよう、manifestを適切に書いておく必要があります。例えば、1.5のAPIが必要であれば、そのSDKバージョンである”3″を指定します。

  
   ...
   
   ...
  

もし新しい機能がアプリに不可欠なものでなければ、次のような方法で、古いデバイスでも失敗しないようなコードを書くことができます。

リフレクションを使う

android.os.Debugはこれまでも存在していたクラスですが、android.os.Debug.dumpHprofData(String filename)は1.5で新しく追加されたAPIです。したがって、古いデバイスでこのメソッドを直接呼び出すと失敗してしまいます。

こうしたメソッドをうまく呼び出す最も簡単な方法は、リフレクションを使うことです。
まず新しいAPIが存在するかどうかチェックし、その結果をMethodオブジェクトとしてキャッシュしておきます。メソッドを呼び出すときには、Method.invokeを呼び出して、結果をアンボクシング(un-boxing)します。(訳注:この例の場合、Exceptionを元のメソッド呼び出しに合わせてスローしなおしたりしている)

public class Reflect {
   private static Method mDebug_dumpHprofData;

   static {
       initCompatibility();
   };

   private static void initCompatibility() {
       try {
           mDebug_dumpHprofData = Debug.class.getMethod(
                   "dumpHprofData", new Class[] { String.class } );
           /* success, this is a newer device */
       } catch (NoSuchMethodException nsme) {
           /* failure, must be older device */
       }
   }

   private static void dumpHprofData(String fileName) throws IOException {
       try {
           mDebug_dumpHprofData.invoke(null, fileName);
       } catch (InvocationTargetException ite) {
           /* unpack original exception when possible */
           Throwable cause = ite.getCause();
           if (cause instanceof IOException) {
               throw (IOException) cause;
           } else if (cause instanceof RuntimeException) {
               throw (RuntimeException) cause;
           } else if (cause instanceof Error) {
               throw (Error) cause;
           } else {
               /* unexpected checked exception; wrap and re-throw */
               throw new RuntimeException(ite);
           }
       } catch (IllegalAccessException ie) {
           System.err.println("unexpected " + ie);
       }
   }

   public void fiddle() {
       if (mDebug_dumpHprofData != null) {
           /* feature is supported */
           try {
               dumpHprofData("/sdcard/dump.hprof");
           } catch (IOException ie) {
               System.err.println("dump failed!");
           }
       } else {
           /* feature not supported, do something else */
           System.out.println("dump not supported");
       }
   }
}

initCompatibilityはスタティックイニシャライザであり、メソッドの有無をチェックします。これが成功した場合にはdumpHprofDataメソッドが存在しており、同じセマンティックスのプライベートメソッドを呼び出します。戻り値と例外は、元のメソッドと同じように返されます。

fiddleメソッドは、アプリにおいて、どのように新しいAPIを呼び出したり、新しいメソッドの有無により挙動を変えるのかを示しています。

さらにメソッドが必要であれば、さらにMethodフィールド、フィールドのイニシャライザ、呼び出しラッパーをクラスに追加すればよいです。

しかし、そのメソッドがこれまで定義されていないクラスに宣言されている場合、このアプローチだとやや複雑になります。また、Method.invoke()によるメソッド呼び出しは直接呼び出すよりも、かなり遅くなってしまいます。次のラッパークラスを使うと、これを軽減することができます。

ラッパークラスを使う

新しいAPIをすべてラップするクラスを作ります。ラッパークラスのメソッドは、実際のメソッドを呼び出して、その結果を返します。

もし対象となるクラスやメソッドが存在すれば、その挙動は直接呼び出すのと同じです(わずかなオーバーヘッドはありますが)。もし存在しなければ、ラッパークラスの初期化が失敗することにより、アプリはそれを知ることができます。

次のような新しいクラスNewClassがあるとします。

public class NewClass {
   private static int mDiv = 1;

   private int mMult;

   public static void setGlobalDiv(int div) {
       mDiv = div;
   }

   public NewClass(int mult) {
       mMult = mult;
   }

   public int doStuff(int val) {
       return (val * mMult) / mDiv;
   }
}

NewClassのためのラッパークラスを作ります。

class WrapNewClass {
   private NewClass mInstance;

   /* class initialization fails when this throws an exception */
   static {
       try {
           Class.forName("NewClass");
       } catch (Exception ex) {
           throw new RuntimeException(ex);
       }
   }

   /* calling here forces class initialization */
   public static void checkAvailable() {}

   public static void setGlobalDiv(int div) {
       NewClass.setGlobalDiv(div);
   }

   public WrapNewClass(int mult) {
       mInstance = new NewClass(mult);
   }

   public int doStuff(int val) {
       return mInstance.doStuff(val);
   }
}

このクラスは、元のクラスのコンストラクタとメソッドにつき1つのメソッドと、新しいクラスが存在するかテストするためのスタティックイニシャライザを持っています。新しいクラスが利用できなければ、WrapNewClassの初期化は失敗するので、うっかりラッパークラスを使ってしまうことはありません。checkAvailableメソッドは強制的にクラスを初期化するために使います。

アプリからは次のようにラッパークラスを利用します。

public class MyApp {
   private static boolean mNewClassAvailable;

   /* establish whether the "new" class is available to us */
   static {
       try {
           WrapNewClass.checkAvailable();
           mNewClassAvailable = true;
       } catch (Throwable t) {
           mNewClassAvailable = false;
       }
   }

   public void diddle() {
       if (mNewClassAvailable) {
           WrapNewClass.setGlobalDiv(4);
           WrapNewClass wnc = new WrapNewClass(40);
           System.out.println("newer API is available - " + wnc.doStuff(10));
       } else {
           System.out.println("newer API not available");
       }
   }
}

新しいクラスの有無は、checkAvailableが成功するか失敗するかでわかります。これにより、新しいクラスが存在しないのに、うっかり使おうとしてしまうのを防ぐことができます。

存在しないクラスへの参照のあるクラスを受け入れたくないとバイトコードベリファイアが判断すると、checkAvailableを呼び出す前に失敗することに注意しましょう。このようにコードを書いておくと、例外がベリファイアからでも、Class.forName呼び出しからでも、結果は同じになります。

新しいメソッドが追加された既存のクラスをラップするときには、ラッパークラスに新しいメソッドを追加するだけでよいです。既存のメソッドは直接呼び出しましょう。WrapNewClassのスタティックイニシャライザは、リフレクションにより1度だけチェックするよう拡張されることになるでしょう。
(訳注:「新しいメソッドが追加された既存のクラスをラップするとき」と解釈したけど、「すでにラップしているクラスに新しいメソッドが追加されたとき」とも取れるかも。いずれにせよ、リフレクションと組み合わせて使うということか)

テストが重要

サポートしたいすべてバージョンでテストをしなければなりません。「試してないなら、動かない」と覚えておきましょう。

1.5では、”Android Virtual Devices”というものが導入されたので、それぞれのAPIレベルに対して、AVDを用意しておくことができます。これを使うと、異なるバージョンを並べて比較することもできるでしょう。

ドキュメントのAndroid Virtual Devicesや”emulator -help-virtual-device”の出力を参考。