Developer's Blog

NSProxy を使って UIWebView のイベントハンドリングをフックする


head

こんにちは。開発担当の福井です。

突然ですが、みなさん NSProxy をご存じでしょうか?
NSProxy は Foundation の中で唯一 NSObject を継承しないクラスです(NSProxy のサブクラスを除く)。
また、その実装はほとんどありません。

今回はその NSProxy を使って view に対するメソッド呼び出しをフックしてみようというお話です。

NSProxy の使い方

名前からも推測できるように、NSProxy は Proxy パターンを実現するためのクラスです。
メッセージの呼び出しが動的に解決される Objective-C において Proxy オブジェクトを実現するのは実に簡単です。
NSProxy は、ただ自身に送られたメッセージを、そのまま別のオブジェクトに受け流すことで Proxy としての機能を実現します。

Proxy オブジェクトを作ってみる

簡単だと言いましたが、実際のところ何を言っているのかよくわかりませんね。
実際にメソッドの呼び出しをロギングする簡単な Proxy オブジェクトを作ってみましょう。
NSProxy は単独で使うことはほぼ無く、だいたいはサブクラスを作って使うことになります。

@interface MyProxy : NSProxy
@property (strong, nonatomic) NSObject *target;
- (instancetype)initWithTarget:(NSObject *)target;
@end

@implementation MyProxy

- (instancetype)initWithTarget:(NSObject *)target
{
    _target = target;
    return target ? self : nil;
}

- (void)forwardInvocation:(NSInvocation *)invocation
{
    if (_target) {
        invocation.target = _target;
        [invocation invoke];
    }
}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel
{
    NSLog(@"%@", NSStringFromSelector(sel));
    return _target ? [_target methodSignatureForSelector:sel] : [super methodSignatureForSelector:sel];
}

@end

これで、target へのメソッド呼び出しをログ出力する Proxy オブジェクトが完成しました。
ログの出力は methodSignatureForSelector: に仕込みます。
methodSignatureForSelector: は NSObject にもあるメソッドで、Objective-C でメソッド呼び出しを動的に解決するためのしくみのひとつです。

NSProxy は NSObject のサブクラスではありませんので、init メソッドは自分で実装しない限り存在しません(NSObject Protocol のみを継承しています)。
initWithTarget: で [super init] を呼んでいないのもそのためです。
余談ですが、仮に initWithTarget: を実装しないと

MyProxy *p = [MyProxy alloc];

のように alloc の呼び出しのみで使えます。
Objective-C に慣れたエンジニアには非常に奇妙に見えますね。

Proxy オブジェクトを使ってみる

MyProxy ができたところで実際に使ってみましょう。
試しに NSString のふりをさせてログを出力します。

NSString *hello = @"proxy!";
NSString *proxy = (NSString *)[[MyProxy alloc] initWithTarget:hello];  // Proxy に NSString のふりをさせる
NSLog(@"%@", [@"Hello, " stringByAppendingString:proxy]);

このコードを実行してみると…

ProxyTest[8441:a0b] length
ProxyTest[8441:a0b] length
ProxyTest[8441:a0b] getCharacters:range:
ProxyTest[8441:a0b] Hello, proxy!

3つのメソッド呼び出しが出力されました。
stringByAppendingString: の実装は引数で渡されたオブジェクトに対し、length と getCharacters:range: を呼んでいることがわかります。
また、NSLog も正常に動作しているので Proxy オブジェクトが自身に送られたメッセージをきちんと内包する NSString に受け流していることもわかります。

内部的にどのようなメソッド呼び出しがされているかわかっておもしろいので、是非他にも色々なパターンを試してみてください。

NSProxy を使ってイベントハンドリングをフックする

Sleipnir Mobile では、この NSProxy によるメソッド呼び出しのフックを利用して、”画面下部をタップして、ツールバーを表示する操作”を実装しています。

tap

画像はフルスクリーン時のものですが、非フルスクリーン時でも基本的には同じです。
Safari でもおなじみの操作ですね。

この操作を実現するにはおおきく分けて以下の2つが実現できればよさそうです。

  • UIWebView のリンクをタップしたときの動作を抑制する
  • UIWebView へのタップイベントをフックする

やっかいなのは UIWebView はサブクラスを作ってはいけないということです。
なんとか外側のしくみを使って実現できないか考えてみます。

UIWebView のイベントハンドリングを抑制する

通常 UIWebView はタップされた位置にリンクがあれば、そのリンクに遷移します。
しかし、ここではツールバーを表示するアクションとしてタップを利用したいため、この動作を抑制する必要があります。

そこで、ツールバーの表示に差し替えたい領域で発生したタップイベントのハンドリングを UIWebView でなく、その scrollView に変更します。
タップ領域に view を置き、hitTest:withEvent: で UIWebView の scrollView を返すようにすれば UIWebView 独自の動作を抑制することができます。

ポイントはタップされた view でなく、UIWebView の scrollView を返す点です。
ここで、view を返してしまっては、下の領域からドラッグを開始したときに UIWebView がスクロールせず、下部に見えないボタンがあるのと同じになってしまいます。

Proxy を使って scrollView へのタップイベントを追加する

後は scrollView のイベントをフックできれば目的は達成できます。
UIScrollView は panGestureRecognizer を持っていますので、addTarget:action: すればフックできそうですが、Sleipnir Mobile では多数の UIWebView を使うため、その全てに addTarget:action: するのは少し面倒ですし、なんだか野暮ったいです。

そこで、hitTest:withEvent: で返すものを Proxy でくるんでしまいましょう。
hitTest:withEvent: では本来 UIView を返しますが、これを Proxy に差し替えてしまうというのがポイントです。
Proxy は Responder Chain に対しては本来返したかった scrollView のフリをします。

そして、ここで返した Proxy によって、先ほど作った MyProxy のようにメソッドの呼び出しをフックできますので、scrollView に発生したイベントを監視します。
UIView に対するイベントハンドリングはそれぞれ

  • touchesBegan:withEvent:
  • touchesMoved:withEevnt:
  • touchesEnded:withEvent:
  • touchesCancelled:withEvent:

で拾えますので、Proxy で scrollView に対するこれらの呼び出しを監視し、タップイベントを検出したらツールバーを出すアクションを発火すれば完成です。
UIWebView より外側のしくみだけを使って、いい感じに UIWebView の機能を抑制しつつ、タップイベントをハンドリングする機能を追加することができました。

まとめ

NSObject じゃない唯一のクラス、NSProxy の概要と使い方、Sleipnir Mobile を実例に view のイベントハンドリングに使う方法を紹介しました。
頭の片隅にでも置いておくとイザというときに役に立つかもしれません。

関連

Copyright © 2019 Fenrir Inc. All rights reserved.