Objective-C
iOS
Swift
Rollout

iOSアプリに審査なしでパッチを当てるライブラリを作ってみた

More than 1 year has passed since last update.

ドワンゴAdvent Calendar21日目。

はやく耳からうどんを垂れ流したいと思う今日このごろです。

Rollout.io

Rollout.ioというAppleの審査を介さず、アプリのアップデートを行うことのできるサービスがあります。公開しているアプリに不具合等があったとき、すぐに修正して審査に出してもAppleの審査を通過するまで時間がかかり非常にヤキモキした、ということはないでしょうか。

しかしRollout.ioを利用すると、問題箇所を修正したパッチをjavascriptで作成し、サーバ経由で配布することでアプリの起動時や復帰時に問題のコードの差し替えができるようになります。これによって緊急的なバグがあった場合は取り急ぎRollout.ioを利用してパッチを配布し、並行してアプリを修正して審査に出すことで不具合が出ている時間を可能な限り短くすることが出来るわけです。

国内ですとYahoo! Japanさん等が導入しているようでこちらの記事を見る限り、アプリのビルド時に生成されるdSYMを予めRollout.ioのサーバに登録しておき、問題のあるクラスのメソッドをまるごとjsのコードで置き換えてしまうという仕組みのようです。

そもそも審査を通さずにアプリの挙動を変更できるなんてAppleの規約的に大丈夫なのか?という疑問はありますが、Rollout.ioの公式説明を見る限り、javascriptコードをダウンロードしてWebKitもしくはJavascriptCoreで実行することは、Appleのガイドラインでは例外条項として盛り込まれているためセーフのようです。

Appleの規約でも問題なくて、審査なしですぐにパッチが当てられるなんて最高じゃん!と感じますが、シンボルだけとはいえ自社のアプリの情報を外部サービスに配置しておくのはなにかと抵抗があるとか、Rollout.ioが改竄されてアプリに不正なコードが埋め込まれるんじゃないかと心配するところもあるかもしれません。(もしくは単純にRollout.io高ぇとか)

それなら自分でパッチを当てる機構を作ってパッチは自社サーバとか好きな場所に配置できたら便利なんじゃないか、ってことで今回はRollout.ioみたいな機能を簡単にアプリに組み込むためのライブラリを自前で作ってみました。

ObjectiveMonkey

https://github.com/saiten/ObjectiveMonkey

というわけで作ったのがコレ。

ObjectiveMonkeyはRollout.ioのようにjsスクリプトからアプリに対して動的にパッチを当てることが出来るライブラリです。アプリ内のObjectiveCコードであれば、ある程度パッチを適用することが可能です。ただしRollout.ioのようなサーバーサイド実装は一切持たないため、パッチスクリプトの作成・ホストなどは全て自前で用意する必要があります。

導入

cocoapodsで導入可能です。

pod 'ObjectiveMonkey', '~> 0.1.0'

任意のURLにパッチスクリプトを配置し、それを適用したい場合はAppDelegate内のdidFinishLaunchingWithOptionsで以下のように記述します。

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    // パッチ適用
    ObjectiveMonkey.default().patch(from: URL(string: "https://saiten.co/test/patch.js")!)
    return true
}

アプリ側の実装はこれだけです。

パッチスクリプト

今回は以下のようなボタンをタップするとアプリがクラッシュしてしまうコードにパッチを当てて修正をしたいと思います。

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
    }

    @IBAction func pressButton(sender: Any) {
        assert(false) // fail anyway
    }
}

このコードの例ではViewControllerクラスのpressButtonメソッド内で例外が発生してしクラッシュしてしまいます。ですのでこのpressButtonメソッドにパッチを当て、処理を置き換えてしまうことにしましょう。

以下は実際のパッチスクリプトです。(ES6が使えます)
Rollout.ioのパッチコードをだいぶ参考にしています。

$p.addPatch('ViewController', 'pressButtonWithSender:', (
  self,  // OMKObjcBox<ViewController *>
  sender // OMKObjcBox<id>
) => {
  $p.consoleLog("patched pressButton");

  // change background color
  let UIColor = $p.NSClassFromString('UIColor'); // -> OMKObjcBox<Class>
  let color = UIColor.call('colorWithRed:green:blue:alpha:', Math.random(), Math.random(), Math.random(), 1.0); // -> OMKObjcBox<UIColor *>
  self.call('view').call('setBackgroundColor:', color);
});

$pはObjectiveMonkeyが定義しているランタイムオブジェクトです。$pにはパッチの追加処理のほかに、各種ユーティリティメソッドが含まれています。

上記のスクリプトでは$pが持つaddPatchメソッドを使ってパッチを追加・適用しています。addPatchメソッドの第1引数にクラス名、第2引数にパッチを適用するメソッドのセレクタ文字列を指定します(swiftコードをobjc表記に変えるのでここではpressButtonWithSender:となっています)。そして第3引数で実際に置換されるパッチコードを記述します。

パッチコードが取れる引数の数や戻り値の型はパッチをあてるメソッドと同じですが、引数で渡されるid型のネイティブオブジェクトはOMKObjcBoxというラッパーオブジェクトでラップされます。OMKObjcBoxでラップされたネイティブオブジェクトならばcallメソッドを利用してパッチからネイティブコードを呼び出すことが出来ます。

$pの持つNSClassFromStringメソッドでOMKObjcBoxでラップされたClassを入手することができるため、インスタンスの生成も行えます。

let UIColor = $p.NSClassFromString('UIColor');
let black = UIColor.call('blackColor') // [UIColor blackColor]と同義

そして引数でselfを受け取ることが出来るため、自身の持つメソッドを呼び出したり、プロパティを書き換えたりすることも可能です。

self.call('view').call('setBackgroundColor:', color); // self.view.backgroundColor = color と同義

また親クラスのメソッドを呼び出したり、

$p.addPatch('ViewController', 'viewDidLoad', (
  self  // OMKObjcBox<ViewController *>
) => {
  self.callSuper('viewDidLoad'); // [super viewDidload]; と同義  
});

パッチ適用前のメソッドを呼び出したりすることも可能です。

$p.addPatch('ViewController', 'sumWithA:B:C:', (
  self, // OMKObjcBox<ViewController *>
  a, // int
  b, // int
  c  // int
) => {
  return self.originalImplementation(a, b, c) + 10;
});

仕組み

method swizzlingとNSInvocation、JavascriptCoreというObjectiveCの黒魔術を駆使して実装しています。

まず、$paddPatchメソッドが実行されるとパッチ対象となるクラスとセレクタが渡されるので、それをもとに該当のMethodを取得します。(クラスメソッドがインスタンスメソッドかはセレクタから判別できないのでとりあえず両方引っ張ってきてます。)

Class klass = NSClassFromString(className);
SEL selector = NSSelectorFromString(methodName);
Method classMethod = class_getClassMethod(klass, selector);
Method instanceMethod = class_getInstanceMethod(klass, selector);
if(classMethod || instanceMethod) {
   :
}

次にそのMethodからtype encodingを取得し(メソッドの引数の型や戻り値の型を表す文字列)、パッチ対象のメソッドと同じtype encodingのメソッドを追加します。

const char *typeEncoding = classMethod ? method_getTypeEncoding(classMethod) : method_getTypeEncoding(instanceMethod);
if(!class_addMethod(klass, patchSelector, (IMP)ObjectiveMonkey_PatchMethod, typeEncoding)) {
    NSLog(@"failed add method");
}

このとき指定しているObjectiveMonkey_PatchMethodは可変長引数を取る関数で、どんなメソッドが指定されても対応できるようにしています。

void *ObjectiveMonkey_PatchMethod(id self, SEL _cmd, ...)
{
    va_list list;
    va_start(list, _cmd);

    return [[ObjectiveMonkey defaultMonkey] invokePatchWithTarget:self
                                                         selector:_cmd
                                                        arguments:list];
}

メソッドの追加ができたら追加したメソッドとオリジナルのメソッドをmethod swizzlingします。

Method patchMethod = class_getInstanceMethod(klass, patchSelector);
if(classMethod) {
    method_exchangeImplementations(classMethod, patchMethod);
} else {
    method_exchangeImplementations(instanceMethod, patchMethod);
}

これで置き換えたObjectiveMonkey_PatchMethodから追加されたスクリプトを呼び出すことでパッチを実現しています。

問題点

とりあえず箇条書き。

  • swiftには未対応
    • ObjC依存な実装なので、swiftなクラスやメソッドには一切対応してません。(´◔‸◔`)
    • Rollout.ioの方は疑似method swizzlingと呼ぶ方法で一部実現しているようです。 (コンパイラごと置き換えて、各メソッドにパッチ処理ができるように処理を埋め込む形なのであんまりやりたくない感じ。)
  • 起動直後に落ちるバグとかには対応できない
    • スクリプトをダウンロードして適用するまでに若干のタイムラグがあるため、起動直後に落ちるバグなんかには対応できません。そういうのは特急審査とかで対応しましょう。
  • バグが多分いっぱいある
    • できたばかりなのでまだまだ安定してないです。(´◔‸◔`)
    • 型変換周りがものすごい怪しい

まとめ

動的パッチに対する意見はいろいろあると思いますが(審査が形骸化する、審査出したほうが早い、二度手間などなど)、あくまでバグ修正や簡単な挙動の変更に止めて審査すり抜けに利用せず、適切なセキュリティリスクを考慮できれば個人的には十分利用価値がある方法だなーと感じています。

興味がある方はぜひ使ってみてください。