UICollectionView で UITableView のセクションヘッダー風の SupplementaryView を実装する

UICollectionView は昔なら UITableView を使って頑張って実装していようなグリッドレイアウトな UI を UITableView ライクな I/F で実装できる素敵なやつです。UITableView ライクな I/F とは言いましたが実は細かい挙動が UITableView とは違っています。UICollectionView で UITableView のセクションヘッダーのようなものを実装するには SupplementaryView を使います。でも普通に UICollectionViewFlowLayout を使っても SupplementaryView はスクロールすると Cell と同じようにそのまま通り過ぎてしまいます。UITableView のセクションヘッダーみたいに同一セクション内の場合に上端に居続けてくれません。UITableView のセクションヘッダーみたいなフローティングした(上端に張り付いたような)セクションヘッダーを実装したい。じゃあ、似たような挙動になるようにしようっていうのが今回の話です。

とりあえずググる

特にライブラリとしてまとまっているものが見つけられませんでした。(あとで調べたら vast-eng/uicollectionview-gridlayoutというのがありました。)しかし、stackoverflow で同じようなことをしたいという質問が見つかりそこから gist に上げられたコードを見つけました。

Sticky Headers at the top of a UICollectionView

やや難あり

さっきの gist ですが、確かに UITableView のセクションヘッダーみたいに同一セクション内の場合に上端に居続けてくれるのですが、全体的に位置が下がり気味。何かが違う。

どノーマルの UICollectionViewFlowLayout を使ったものがこちら

f:id:griffin-stewie:20131020120023p:plain

gist のコードをそのまま適用したのがこちら

f:id:griffin-stewie:20131020120021p:plain

修正

ずれる理由は UICollectionViewFlowLayoutsectionInset の値が考慮されていませんでした。修正後のコードはこちら。

UIEdgeInsets sectionInset = self.sectionInset;

origin.y = MIN(
                MAX(
                    contentOffset.y + cv.contentInset.top,
                    (CGRectGetMinY(firstObjectAttrs.frame) - topHeaderHeight - sectionInset.top)
                    ),
                (CGRectGetMaxY(lastObjectAttrs.frame) - bottomHeaderHeight + sectionInset.bottom)
                );

公開

修正したコードを UICollectionViewFlowLayout のサブクラスとして github にあげています。もし使えそうなら使って見てください。

CSNFloatingHeaderViewFlowLayout

CocoaPods でインストールできます。

pod 'CSNFloatingHeaderViewFlowLayout', '~> 0.0'

Thank you toblerpwn.

はてなインターンのサンプルコードを読んでの感想

はてなさんが

はてなインターンで利用したiOSアプリ等のサンプルコードを公開しました - Hatena Developer Blog

という素敵なサンプルコードを公開してくれたので、好き勝手に感想を書いてみたいと思う。本来は Web アプリ側も動かしたかったんだけど、ウマく環境が作れなかったのでアプリはコードを読んだだけで動かしてはないです。あと、細かい検証とかしてないので勘違いとかしていると思うので指摘してください。

参考になった真似したい

NSError と UIAlertView の使い方

NSError の内容ベースでエラーアラートを出すのは理にかなっていると思った。OS 側が返す NSError は期待する値が入っていない不親切な場合があるので、オリジナルの NSError から一度適切な値を持った NSError を再生成して返すクラスを介したらいいのかもしれない。

すっきりした UIViewController

iOS アプリを作り慣れていない人は特に直接ネットワーク処理を書いてしまったりモデルの管理コードを書いてしまいがちで UIViewController の肥大化するのがありがちなパターンだと思う。しかし、このサンプルはネットワーク処理、モデル管理等を綺麗に分けているのは見習うべきだと思った。

通信とモデルの管理の関係

普段僕は Web API を叩くクラスとそれを元にモデル生成するのを同じクラスに書いていたけど、このコードのようにネットワークはシンプルに Web API を適切に触ることだけにし、パースやモデルの生成、管理はモデル管理クラスに分けた方が綺麗だし、のちのメンテナンス性や拡張性が確保されていいと勉強になった。

isEqual:hash をちゃんと実装

結構面倒で実装してないときがあるんだけど、モデルクラスがちゃんと isEqual と hash メソッドをオーバーライドしているのが素晴らしい。特に hash メソッドを実装するのを忘れていると NSSet に突っ込んだ時の振る舞いが期待通りにならずに軽くハマるので注意。

KVO をウマく使っている

KVO を使って ViewController がモデルを見ているあたりは Mac アプリ実装経験者っぽくてなんか素敵。NSErrorlocalizedFailureReason とか使っている点も同様。KVO を使って、TableView の更新をかけているのは凄く勉強になる。mutableArrayValueForKey: は知らなかった。勉強になった。

NSIndexSet の使い方

indexSet をうまく使っているのは素敵だと思った。 mattt が WWDC でも言っていたので使う機会があったら自分も使いたいと思ってる。

気になったところ

UIAlertView+NSError

self.message = [[NSArray arrayWithObjects:[error localizedFailureReason], [error localizedRecoverySuggestion], nil] componentsJoinedByString:@"\n"];

一瞬、[error localizedFailureReason]nil だったら落ちるんじゃないかと思ったけど、よく考えると arrayWithObjects:nil 終端として動作するので落ちない。[error localizedFailureReason][error localizedRecoverySuggestion]nil なら self.message はカラのままで済むのでスマート。ただ実際にあるかどうかはわからないが、逆にいうと [error localizedRecoverySuggestion] が入っていて[error localizedFailureReason]nil を返す場合には、先に渡されている nil の評価がされてしまって仮に値が入っていても [error localizedRecoverySuggestion]self.messagenil となる。この挙動を忘れているとある日ハマる。もっとマジメに実装するなら NSMutableArraynil チェックをしながら足していく感じになるのかもしれない。

NSDictionary に入れる値の nil チェック

IBKMInternBookmarkAPIClient が引数の nil チェックもせずに NSDictionary に突っ込んでいるのは落ちる可能性があるので危険。使う側が意識すれば落ちないだろうけど、渡されたデータの妥当性チェックはデータをもらう側に責任があると思うのでチェックした方がいいと思う。

addObserver:~ したら remove~ する

KVO の監視開始、NSNotificationCenter への登録をしても、それらの解除を書いていないのは良くない。

Be sure to invoke removeObserver: or removeObserver:name:object: before notificationObserver or any object specified in addObserver:selector:name:object: is deallocated.

NSNotificationCenteraddObserver:selector:name:object: にも書いてある。

NSNotificationCenter の remove は複数回読んでも害はないけど、KVO の remove は必ず add を一対でないと行けない(add していない状態で remove すると落ちる)のでライフサイクル的に一対になるようなところに入れるしないといけないので注意が必要。

24時間表示を Off でもちゃんと機能するように NSDateFormatter を使う

NSDateFormatter に locale を "en-US" でセットしていないけど、24時間表示を Off にしている端末でも期待する動作をするのか不安。timeZone を指定していると大丈夫なんだろうか?未検証。

まとめ

気になる点はあるけど、これだけ綺麗にまとまって、なおかつそれなりに実戦経験がある人でもやってなさそうだったり知らなそうなことをサラッと見せてくれているので、このコードで Objective-C / Cocoa を勉強するインターン生は幸せもんだなぁと思った。僕も来年インターンで入れないかな?

アプリの画面を開いているアプリケーションのプレビュー画面から隠す

iOS 7 以前でも、アプリ実行中にホームボタンを押してまた、そのアプリに戻ってきた場合には OS が作成した画面のスクリーンショットが表示されていました。プライバシーが気になるようなアプリでは Home に戻る直前の画面をそのまま次回前面になったときに使われると困ることがあります。家計簿等のお金を扱うアプリだと独自に暗証番号でロックできるようになっていますが、私が使っている「マネー2」は前面に戻ってきた時にちらっと前の画面がうつってからロック画面に移行してて何だかなぁと思っていました。iOS 7 になってからはホームボタンを2回押して出てくる最近使ったアプリ一覧画面にプレビュー画面が付いて Exposé 風になりました。Exposé 風になったことでますますこの問題が出てきてしまって気になったのでどうすれば良いのか試してみました。

iOS App Programming Guide

iOS App Programming Guide には

Remove sensitive information from views before moving to the background. When an app transitions to the background, the system takes a snapshot of the app’s main window, which it then presents briefly when transitioning your app back to the foreground. Before returning from your applicationDidEnterBackground: method, you should hide or obscure passwords and other sensitive personal information that might be captured as part of the snapshot.

と書かれています。アプリ側でよしなに対処するべきなんですね。

普通に実装しただけの場合

普通に前面にアプリがいるとき

f:id:griffin-stewie:20131011010803p:plain

そのまま Home ボタン2回押し

f:id:griffin-stewie:20131011010752p:plain

当然ながら凜々しい猫が丸見えです。

対策を実装

今回試したのは割と手抜きです。試した内容は以下のような感じです。

  • AppDelegate に Window と同じサイズの UIView を用意し、適当な色をつけておく
  • 各種ライフサイクルのデリゲートメソッドでその View を 表示 / 非表示 させる

View の実装

- (UIView *)privacyView
{
    if (_privacyView == nil) {
        _privacyView = [[UIView alloc] initWithFrame:self.window.frame];
        _privacyView.backgroundColor = [[UIColor orangeColor] colorWithAlphaComponent:0.95];
    }
    return _privacyView;
}

View の出し入れ

以下のようなメソッドを用意しておいて

- (void)showPrivacyView
{
    [self.window addSubview:self.privacyView];
}

- (void)removePrivacyView
{
    [self.privacyView removeFromSuperview];
}

Application のライフサイクルで出し入れを実装します。

- (void)applicationWillResignActive:(UIApplication *)application
{
    [self showBluredPrivacyWindow];
}

- (void)applicationDidBecomeActive:(UIApplication *)application
{
    [self removeBluredPrivacyWindow];
}

applicationWillResignActive: はアプリが前面にあるときに Home ボタン2回押しの時も、Home に戻った時にも呼ばれます。applicationDidBecomeActive: はアプリが前面に戻ってきたときに呼ばれます。こちらもアプリが前面にあるときに Home ボタン2回押しした時も、Home に戻った時にも呼ばれます。

これらを実装すると以下のような感じになります。このスクリーンショットはアプリが前面にあるときに Home ボタン2回押しした時のものです。

f:id:griffin-stewie:20131011010741p:plain

折角なので iOS 7 っぽい磨りガラス風に

ということで iOS 7 風にしてみます。UIImage に対してブラーを掛けた画像を生成する部分は Apple のサンプルコードにある UIImage+ImageEffects を使ってみます。ただ、実際にはアプリの画面自体(View)を磨りガラス風画像にする必要があります。iOS 7 から - (UIView *)snapshotViewAfterScreenUpdates:(BOOL)afterUpdates がありますが、これは戻り値が UIView なので用途にあっていません。- (BOOL)drawViewHierarchyInRect:(CGRect)rect afterScreenUpdates:(BOOL)afterUpdates なら同様の処理を context に draw してくれるのでこちらを使って今の画面のスクリーンショットを取るようにします。

任意の View のスクリーンショットを撮って磨りガラス風の画像を生成するコードはこんな感じになります。

- (UIImage *)blurredSnapshotWithBlurType:(BlurEffectsType)type
{
    /// Original Code: iOS 7 blurring techniques — Damir Tursunović http://damir.me/posts/ios7-blurring-techniques
    UIGraphicsBeginImageContextWithOptions(self.bounds.size, NO, self.window.screen.scale);
    
    // There he is! The new API method
    [self drawViewHierarchyInRect:self.frame afterScreenUpdates:NO];
    
    // Get the snapshot
    UIImage *snapshotImage = UIGraphicsGetImageFromCurrentImageContext();

    // Now apply the blur effect using Apple's UIImageEffect category
    UIImage *blurredSnapshotImage = nil;
    switch (type) {
        case BlurEffectsTypeLight:
            blurredSnapshotImage = [snapshotImage applyLightEffect];
            break;
        case BlurEffectsTypeExtraLight:
            blurredSnapshotImage = [snapshotImage applyExtraLightEffect];
            break;
        case BlurEffectsTypeDark:
            blurredSnapshotImage = [snapshotImage applyDarkEffect];
            break;
        default:
            break;
    }

    // Be nice and clean your mess up
    UIGraphicsEndImageContext();
    
    return blurredSnapshotImage;
}

ImageContext を作って、- (BOOL)drawViewHierarchyInRect:(CGRect)rect afterScreenUpdates:(BOOL)afterUpdates で画面を draw し、Context から画像化。その画像を磨りガラス風画像に再加工して書き出します。

実際にこれを使うのには先ほどのメソッドを少し変更します。

- (void)showPrivacyView
{
    self.privacyView.backgroundColor = [UIColor colorWithPatternImage:[self.window blurredSnapshotWithBlurType:BlurEffectsTypeLight]];
    [self.window addSubview:self.privacyView];
}

Window を磨りガラス風画像にして、UIColor にして view の backgroundColor に突っ込みます。綺麗に実装するのなら self.privacyView を UIImageView にするべきですね。この例では手抜き実装となっています。するとこんな感じになります。

f:id:griffin-stewie:20131011010920p:plain

シミュレータだと対策が無効の場合がある

ハマりどころとしては、シミュレータの場合、ホームに戻ってから最近使ったアプリ一覧画面をみても期待通りの動きをしません。実機で試せば問題ありませんでした。このせいで小一時間ハマりました。

まとめ

実際にやってみると簡単です。View を磨りガラス風の画像にするコードはカテゴリメソッド等で使い回しが効く処理なのでプライバシーに配慮するべき画面があるアプリでは実装してみてはいかがでしょうか?iOS 6 以下の場合は一部メソッドが使えないので少し違った対処が必要になるかと思いますが今回の方法をベースすればさほど難しくはないと思います。

サンプルコードをおいておきます。

griffin-stewie/PrivacyProtectSample

Remote Push からの Background Fetch を試してみた

前回は Background Fetch を試してみました。この方法ではアプリだけの実装でバックグラウンドで通信処理等を走らせる事ができますが、実行タイミングが OS 側の学習によるもので任意のタイミングでは発動させることができません。iOS 7 からは Background Fetch 以外にも Remote Push Notification をトリガーに Background Fetch を実行させる機能も追加されています。Remote Push Notification を送るためのサーバサイドの準備が必要ですが、任意のタイミングで実行できる(Rate Limit はあります)のは魅力的です。APNs 環境を作ったことがない僕が APNS 環境の構築から Remote Push Notification から Background Fetch を動かすまでのメモを残しておきます。

APNs 環境構築

今回、僕が使ったのは Heroku + Helios という環境を構築してみました。Heroku は個人開発者が動作検証に使う分には十分に無料の範囲でいろいろとためせて非常に助かります。Helios は AFNetworking の作者であり、Heroku 社員でもある Mattt が作った iOS デベロッパ向けの各種バックエンド機能を実装した Framework です。Heroku 社員でもあるだけに Heroku へのデプロイも簡単です。

まずは、Helios のインストール

gem install helios

次に Helios のアプリケーションを作成

helios new <任意のアプリケーション名>

これだけで必要なファイル群が作成され、git の管轄下になります。

今の Heroku は Ruby 2.0.0 で動いているので Gemfile に以下を追記。

ruby '2.0.0'

APNs に必要な pem ファイルを作成します。このあたりはあらゆるところに情報があるので割愛します。

作成した pem ファイルをディレクトリに追加。

pem ファイルを読み込み、APNs が使えるようにするための設定を config.ru ファイルに追記

service :push_notification, apn_certificate: './apple_push_notification.pem', apn_environment: 'development'

ここまでで下準備が大分整いました。あとは heroku 上にアプリケーションを作成します。 Heroku の基本的なセットアップは事前に終わっていることとします。

今回作成した Helios アプリの git リポジトリディレクトリ内で

heroku create

すると Heroku 上に環境を作ってくれます。このときに URL も出力されるので覚えておいた方があとあと便利です。ここまできたらあとは、

git push heroku master

すれば Heroku 上へのデプロイも完了です。

アプリへの組み込み

Bundle ID の修正

普段の開発ではプロビジョニングプロファイルをワイルドカード OK な設定にしてサンプルアプリを作っているときにはあまり意識していないかも知れませんが APNs では Bundle ID が合致している必要があります。まずは Bundle ID をそろえておきます。プロビジョニングプロファイルもAPNs が受信出来るものをセットします。

APNs を使うためのコード

何はなくとも APNs を使うためには Apple のサーバに登録しないといけません。application:didFinishLaunchingWithOptions: 内あたりに

[[UIApplication sharedApplication] registerForRemoteNotificationTypes:(UIRemoteNotificationTypeNewsstandContentAvailability | UIRemoteNotificationTypeAlert | UIRemoteNotificationTypeBadge | UIRemoteNotificationTypeSound)];

を書きます。個人的に試してみた感想では UIRemoteNotificationTypeNewsstandContentAvailability を宣言しておいた方が Remote Push Notification での Background Fetch が動きやすい気がしています。特にドキュメントに言及されていないように思えますので真偽のほどはわかりません。

Apple の APNs サーバーに対して登録を行ったあとは自身が用意する APNs サーバー側に取得したトークンを登録しておく必要があります。今回は Heroku 上にセットアップした Helios に対してこの作業を行います。今回は Helios を作った Mattt 作の ライブラリ Orbiter を使ってみます。Orbiter は AFNetworking ベースで作られた APNs 登録処理の補助ライブラリです。

- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
    NSURL *serverURL = [NSURL URLWithString:@"http://foobarbuzz.herokuapp.com/push_notification"];
    Orbiter *orbiter = [[Orbiter alloc] initWithBaseURL:serverURL credential:nil];
    [orbiter registerDeviceToken:deviceToken withAlias:nil success:^(id responseObject) {
        NSLog(@"Registration Success: %@", responseObject);
    } failure:^(NSError *error) {
        NSLog(@"Registration Error: %@", error);
    }];
}

上記コードを書くだけでとりあえず動きます。個人的にハマったのは URL です。Orbiter のサンプルだと Heroku 上に作成したアプリの URL を書いていますが、これではダメでした。ググったりして調べてみると Helios 側のソースコードを除いてみたところ rack/push-notification に丸投げしてるようです。ということで http://foobarbuzz.herokuapp.com ではなく http://foobarbuzz.herokuapp.com/push_notification を指定します。Helios の github の README にも書いてありました。

これでアプリを起動して、orbiter の Registration Success まででれば OK。

意気揚々とビルドして

“aps-environment”エンタイトルメント文字列が見つかりません

とか言われたら正しいプロビジョニングプロファイルをセットしていない可能性があるので確認しましょう。僕は2回もやらかしました。

実際に Push を送る

Helios の README には curl を使った例があるのですが、Remote Push Notification による Background Fetch を実行するために必要な "content-available" を送っても Helios 側が受け取れません。ソースコードを読んでみるとそもそも "content-available" には対応してなさそうです。ということでまたまた Mattt 謹製のライブラリ Houston を使って Push を送ります。

houston のインストールは

gem install houston

もしくは他の関連コマンドもまとめてインストールできる nomad を入れても良いかもしれません。

gem install nomad-cli

インストールができたら普通の Push を送ってみます。

apn push DEVICE_TOKE -c PATH_TO_PEM_FILE -m "Test Push"

DEVICE_TOKE は APNs に登録した際に取得したトークンです。Helios 側にも登録したはずなので、http://foobarbuzz.herokuapp.com/admin/#push-notification のように自身の Heroku アプリの URL にアクセスすることでもわかります。 PATH_TO_PEM_FILE は Heroku にデプロイした pem ファイルへのパスです。 -m オプションは Push に乗せるメッセージです。

これで実機で受信できれば OK です。

Remote Push から Background Fetch

前回と同様に Xcode 5 の Capabilities を開いて、Remote notifications にチェックをつけておきます。

次にメソッドの実装。これは前回と似たようなメソッド - (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler を実装します。

- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
    if(![userInfo[@"aps"][@"content-available"] intValue])
    {
        completionHandler(UIBackgroundFetchResultNoData);
        return;
    }
   
    StepCountTweeter *tweeter = [[StepCountTweeter alloc] init];
    [tweeter tweetStepCountWithUserName:nil
                         tweetTextBlock:^NSString *(NSInteger numberOfSteps, NSDate *fromDate, NSDate *toDate, NSError *error) {
                             NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
                             [dateFormatter setLocale:[NSLocale systemLocale]];
                             [dateFormatter setDateFormat:@"MM/dd HH:mm:ss"];
                             NSString *text = [NSString stringWithFormat:@"%@%@ の期間に iPhone 5s を持って %@ 歩 歩きました。 Tweet By RemoteNotification Fetch"
                                               , [dateFormatter stringFromDate:fromDate]
                                               , [dateFormatter stringFromDate:toDate]
                                               , [@(numberOfSteps) stringValue]];
                             
                             UILocalNotification *localNotif = [[UILocalNotification alloc] init];
                             localNotif.timeZone = [NSTimeZone defaultTimeZone];
                             localNotif.alertBody = text;
                             [[UIApplication sharedApplication] presentLocalNotificationNow:localNotif];
                             
                             [self updateBadgeCountWithCurrentDate:toDate];
                             
                             return text;
                         } completionBlock:^(NSData *responseData, NSHTTPURLResponse *urlResponse, NSError *error) {
                             if (error) {
                                 UILocalNotification *localNotif = [[UILocalNotification alloc] init];
                                 localNotif.timeZone = [NSTimeZone defaultTimeZone];
                                 localNotif.alertBody = [error description];
                                 [[UIApplication sharedApplication] presentLocalNotificationNow:localNotif];

                                 completionHandler(UIBackgroundFetchResultFailed);
                             } else {
                                 completionHandler(UIBackgroundFetchResultNewData);
                             }
                         }];
}

"content-available" をチェックしているのは Fetch 処理が必要ない場合に呼ばれたときに余計な通信をしないためにしています。本当はもう少しいろんなシチュエーションに応じて分岐処理が必要かも知れません。

Push から発動させるには apn コマンドに -n オプションをつけておきます。

apn push DEVICE_TOKE -c PATH_TO_PEM_FILE -n -m "Test Push"

Silent Push

次に Silent Push を送ってみます。Silent Push とは Payload にメッセージを含めず "content-available" のみを送るものです。この Silent Push の場合 Remote Push Notification による Background Fetch は発動しますが、通常の Push のように Push そのものは Notofication Center に表示されないことから Silent と呼ばれています。実装によっては、Silent Push を受け取り Background Fetch で動作が完了した際に Local Push を実行するということも出来ます。このような実装をした場合にはエンドユーザーに取っては Notification Center の通知が来たと同時にアプリを開けばその内容に応じたコンテンツがすでに取得済みということが出来ます。

Silent Push の送り方ですが、ここでも難点が。私の環境では

apn push DEVICE_TOKE -c PATH_TO_PEM_FILE -n

とするとエディタが開いてしまいます。

apn push DEVICE_TOKE -c PATH_TO_PEM_FILE -P '{"aps": {"content-available":1}}'

とするか、Houston のサイトにあるようにスクリプトとして実装してしまう方法をとりました。

私の環境では Silent Push を基点に Background Fetch を動かすのは Rate Limit に引っかかっているせいなのか多少間をあけないと実行できませんでした。

トラブルシューティング的ななにか

  • push を登録するときには UIRemoteNotificationTypeNewsstandContentAvailability を宣言しておいた方が Remote Push Notification での Background Fetch が動きやすい気がする
  • うまく Push が発動しない時には unregister してみる
  • いきなり込み入ったことをせずにシンプルにバッジの更新程度で試してみる

感想

期待通りに動かない時があったりしたので、実際にタイムリーに発動させるためにはもう少し研究が必要な気がしますが期待度の高い機能なので取り組んでみる価値は高いと思いました。

Background Fetch を試してみた

先日、「M7 と少しだけ戯れてみた」というエントリでモーションアクティビティを試しに触ってました。その流れで自分の歩数を定期的に Tweet するようにしてみたらおもしろいかなぁと思って折角なので iOS 7 から導入された Background Fetch を使って見ようと思い、その時の内容をメモとして残してみようと思います。

ここに書く実験内容は Apple の審査を通った実績のあるものではない点をご了承ください。

Background Fetch とは

  • iOS 7 から追加された新しい Background Mode のひとつ
  • OS 側が不定期(OS の判断で適切だと思われるタイミング)で - (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler メソッドを Background で呼び出してくれる
  • 実行される最短インターバルの指定が setMinimumBackgroundFetchInterval: メソッドで可能
  • OS からの呼び出しタイミングはアプリの利用状況(パターン)を学習した上で決定される

というもののようです。

feedtailor さんの "そら気温" でも使われています。

Background Fetch でできること

Apple の紹介している例としては、

  • SNS 系アプリのタイムラインの事前取得
  • News 系アプリの新着情報事前取得
  • 天気系アプリの情報取得(このあたりが feedtailor さんの "そら気温" がやっているところ)
  • 写真や動画の共有(別の技術 "Background Transfer" との併用)

があげられています。

具体的には

  • Network 通信
  • アプリケーションバッジの更新
  • Local Notification の発行
  • ローカルファイルの生成(UserDafaults の書き換えやおそらく DB 系操作も)

などが可能です。

ということで、冒頭でも書いたように Background Fetch が発火したタイミングで現在の歩数累計を Background Fetch で Tweet してました。

実装

必要なの作業は以下の通りです。

  1. Bacground Modes を "Fetch" として Info.plist に記述
  2. - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptionssetMinimumBackgroundFetchInterval: を設定
  3. - (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler の中身を実装

1. Bacground Modes を "Fetch" として Info.plist に記述

以下のスクリーンショットのように Xcode 5 上で設定

f:id:griffin-stewie:20130929183626p:plain

2. - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptionssetMinimumBackgroundFetchInterval: を設定

application:didFinishLaunchingWithOptions: 内で setMinimumBackgroundFetchInterval: を呼びます。ここでは定数の UIApplicationBackgroundFetchIntervalMinimum をセットしています。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    [application setMinimumBackgroundFetchInterval:UIApplicationBackgroundFetchIntervalMinimum];
    return YES;
}

3. - (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler の中身を実装

- (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
    StepCountTweeter *tweeter = [[StepCountTweeter alloc] init];
    [tweeter tweetStepCountWithUserName:nil
                         tweetTextBlock:^NSString *(NSInteger numberOfSteps, NSDate *fromDate, NSDate *toDate, NSError *error) {
                             NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
                             [dateFormatter setLocale:[NSLocale systemLocale]];
                             [dateFormatter setDateFormat:@"MM/dd HH:mm:ss"];
                             NSString *text = [NSString stringWithFormat:@"%@ 〜 %@ の期間に iPhone 5s を持って %@ 歩 歩きました。 Tweet By Background Fetch"
                                               , [dateFormatter stringFromDate:fromDate]
                                               , [dateFormatter stringFromDate:toDate]
                                               , [@(numberOfSteps) stringValue]];
                             return text;
                         } completionBlock:^(NSData *responseData, NSHTTPURLResponse *urlResponse, NSError *error) {
                             if (error) {
                                 /// 失敗時には UIBackgroundFetchResultFailed を渡して completionHandler を呼ぶ
                                 completionHandler(UIBackgroundFetchResultFailed);
                             } else {
                                 /// 成功時には UIBackgroundFetchResultNewData を渡して completionHandler を呼ぶ
                                 completionHandler(UIBackgroundFetchResultNewData);
                             }
                         }];
}

- (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler の中で具体的に Background Fetch として実現したいことを実装します。約 30 秒の猶予が与えられます。 StepCountTweeter クラスは今回歩数を Tweet するために僕が作ったクラスで SLRequest を使って Tweet するクラスです。Background Fetch でのポイントは StepCountTweeter クラスの tweetStepCountWithUserName:tweetTextBlock:completionBlock: メソッドの第3引数の completionBlock 内部で - (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler の completionHandler を呼んでいるところです。この completionHandler は必ず呼ぶ必要があります。

Tips

Background Fetch の有効/無効

アプリによっては、Background Fetch 自体は有効にしたいもの ユーザー設定として Background Fetch での動作を On/Off させたいとかそもそも Background Fetch で実行したい処理でログインが必須などの場合があるかと思います。アプリ側の状況で実際に Background Fetch の細かい On/Off が必要な場合は以下のように setMinimumBackgroundFetchInterval: に渡す値で有効 OR 無効の切り替えが行えます。

// 無効にする
[application setMinimumBackgroundFetchInterval:UIApplicationBackgroundFetchIntervalNever];

// 有効にする
[application setMinimumBackgroundFetchInterval:UIApplicationBackgroundFetchIntervalMinimum]; // もしくは任意の NSTimeInterval 

Background Fetch のデバッグ方法

Background Fetch のデバッグ方法は主に 2 つあります。

1つ目はスキームを分けてビルドのオプションでアプリ終了状態からの Background Fetch を試す方法です。

f:id:griffin-stewie:20130929183542p:plain

2つ目は Xcode の Debug メニューから "Simulate Background Fetch" を実行する方法です。この方法の場合にはアプリのプロセスが生きている間に Background Fetch が呼び出されるシチュエーションを試すことができます。

f:id:griffin-stewie:20130929183557p:plain

時間のかかる通信処理

30秒の制限がありますが、"Background Transfer" を併用することで時間のかかる通信処理でも実装可能だと思います。

実験

Background Fetch は OS によってスケジューリングされるということなのでどのくらいのスパンで実行されるのかが気になったので簡単に調べてみました。気になったのは

  • completionHandler に渡す結果によって Background Fetch の実行に影響がでるのか?
  • setMinimumBackgroundFetchInterval: に渡すインターバルの違いで起こる Background Fetch の実行への影響

です。

そこで以下のように値を調整しつつ試してみました。

  1. 取得後の結果判定を Fail にしていた インターバルは UIApplicationBackgroundFetchIntervalMinimum
  2. 取得後の結果判定を NewData に変更した インターバルは UIApplicationBackgroundFetchIntervalMinimum
  3. インターバルを 2時間に広げる

実践してみての気付き

取得結果を UIBackgroundFetchResultFailed にしても、UIBackgroundFetchResultNewData しても特に Background Fetch の実行スパンが広がったり縮まったりしているように感じなかった。

UIApplicationBackgroundFetchIntervalMinimum を指定していると

  • 最短 10 分程度
  • 最長 5 時間

くらいのインターバルで動いている感じだった。

インターバルを 2 時間に広げた場合には

  • 最短 2 時間前後( 2 時間切っている場合もあった)
  • 最長 22.5 時間

くらいのインターバルで動いている感じだった。

なんとなく、新たにビルド&インストールを行ったあとは Background Fetch が実行されるまで時間がかかるように感じた

感想

Apple の話によると Background Fetch はアプリの利用状況に応じて OS 側で呼び出してくれため、今回試してみたサンプルアプリの明示的な起動頻度等が影響しているかもしれないと思いました。アクティブに使われるようなアプリの場合は指定したインターバルに近い頻度で実行されるかもしれないですし、ほぼ起動しないようなアプリの場合は相当間が空くか最悪ほぼ実行されないとかがあるのかも知れないとも思いました。

懸念

実行タイミングを明示的には指定できないもののある程度自由度が高いことができそうです。それ故にいたずらに重い通信処理を Wi-Fi 以外の通信手段の時に行われてしまうことやログ的な情報を送信されたり、広告系の何らかのデータ処理が行われそうで1ユーザーとしては嫌だなぁと思うことがあります。開発者はお行儀の良いユーザーに喜ばれるような実装を心がけて便利で有用なものにしてもらいたいと思います。

M7 と少しだけ戯れてみた

9/20 に無事に iPhone 5s Space Gray 64GB を手に入れました。前日の14時過ぎぐらいからヨドバシ梅田界隈をうろうろし、閉店少し前から列に並び、希望端末の整理券1番を Get し、9時くらいには現品 Get しました。

CMStepCounter と CMMotionActivityManager

そんなことはさておき本題。 iPhone 5s にしか搭載していない M7。これがないと動かない機能が CMMotionActivityManager と CMStepCounter あたりの API です。せっかく iPhone 5s を手に入れたので少し触ってみました。

CMStepCounter

CMStepCounter はその名の通り雑にいうと万歩計。計測自体は特にこのクラスを使わなくても iPhone 5s を持っていると OS 側がカウントしているようです。購入後20日午前中に実行しただけでも2000歩程度カウントされてました。

使い方は超シンプルです。

isStepCountingAvailable で利用可能かどうか確認。

OS の設定画面のプライバシーのところに「モーションアクティビティ」という項目があります。

f:id:griffin-stewie:20130922125426p:plain

以下のようにリストアップされます。

f:id:griffin-stewie:20130922125430p:plain

startStepCountingUpdatesToQueue:updateOn:withHandler: で歩数カウントの更新取得開始です。第1引数が第3引数に渡す handler を呼び出す queue を指定します。 第2引数には handler が呼び出される基準(スパン)の指定です。必ずしも指定した歩数間隔ごとに handler が呼び出されるわけではありません。 第3引数には歩数カウントの更新取得時に呼び出される Blocks を指定します。Blocks にはそのスタート以降の累計歩数、タイムスタンプ、エラーが返ってきます。

stopStepCountingUpdates ストップです。

queryStepCountStartingFrom:to:toQueue:withHandler: は任意の期間の歩数の集計ができます。 第1引数に開始日 第2引数に終了日 第3引数に第4引数に渡す handler を呼び出す queue を指定 第4引数に集計結果を受けとる Blocks を指定します。Blocks には歩数合計とエラーが返ってきます。

API や実際の挙動を見る限り、リアルタイムに(といってもタイミングとしては誤差あり)歩数情報を取得する必要がなければ、queryStepCountStartingFrom:to:toQueue:withHandler: だけで十分に歩数計的な用途として使えそうです。

実際に歩数を測りながら歩いてみましたが僕の場合ほぼ実際の歩数と同じくらいの測定精度に感じました。

CMMotionActivityManager

CMMotionActivityManager はユーザーの移動に関する情報を取得できます。情報の種別としては以下の通りです。

  • stationary・・・静止状態
  • walking・・・徒歩
  • running・・・ランニング中
  • automotive・・・自動車や電車等の乗り物に乗っている状態
  • unknown・・・不明

CMMotionActivityManager の提供する API に関しては CMStepCounter とほぼ同じです。 MotionActivity の更新タイミングの通知受けとりの開始と停止。それと任意の期間の情報取得です。 CMMotionActivityManager はその Motion 情報を CMMotionActivity というモデルクラスに格納して返します。

CMMotionActivity

先に挙げた5種類の状態情報を BOOL で返すインターフェイスと

  • startDate・・・発生時間
  • confidence・・・データの精度

を持っています。

状態情報がなんで enum じゃなく BOOL なのかと思ったのですが、リファレンスによると車で移動中の信号待ちなどでは automotive 且つ stationary という状態があるということなので enum じゃないようです。

用途としてはリファレンスにも書かれているようにナビゲーション系のアプリでユーザーの移動手段に応じたルートの提案等に使えると書かれています。

まとめ

CMStepCounter と CMMotionActivityManager も API としては非常にシンプルです。あとはこれをウマく使えるアイディア次第でおもしろい物が作れるかもしれませんね。

サンプルコード

手っ取り早く試してみたい方は以下にサンプルをおいてますので試してみてください。

griffin-stewie/M7MotionSample

余談

翌日 21 日の昼過ぎにヨドバシ梅田に行ったら Space Gray は容量問わず予約なしですぐに持ち帰れる状況でした \(^O^)/

妻が言ってくれました「リヴァイ兵長もこんな事を言ってたじゃない」

結果は誰にもわからなかった… だから… まぁ せいぜい… 悔いが残らない方を自分で選べ

僕は当日に iPhone 5s Space Gray 64GB が手に入らなければ悔いが残ってしまっていたわけでしたからこれでいいのです。

ボタンのタップ反応エリアの拡大方法

ボタンのタップ反応エリアの拡大方法

アプリを作っていると、実機で触ってみたときに「ボタンが押しにくい」とか「タップのあたり判定がせまい」とかってあるかと思います。たいていの場合はボタンに使っている画像が小さい等が原因だったりします。このような場合の対処方法があらためて探してみても意外と見つからなかったので今更感がありますが普段僕がやっている方法を紹介します。もっと良い方法があれば教えてください。

追記: @k_katsumi さんに指摘頂いた内容を追記しました。

対処方法

1. UI デザイン自体を再考

そもそも論ですが、HIG でも 44pt x 44pt を基準にすることが推奨されているわけですから押しにくい UI デザイン自体を直すのがユーザーのためです。実際にはひっくり返すことになるのでなかなか出来ない話ですw

2. 単純に大きさを大きくする

全然対処方法でもないですねw ビジュアル上イマイチになってしまうことが多いと思います。

3. ボタンに使っている画像を大きくする

「i」ボタンみたいなボタンを実装する時、デザイナーさんは「i」のデザインぎりぎりで切り出したりしている事がまれにあります。それをそのままボタンに組み込むと

  • 「i」アイコンの見た目の大きさ = ボタンの大きさ

となってしまいタップしづらくなります。「i」のデザイン自体の大きさは変えずに周りに透明で埋めた画像で再度切り出してもらうと一番簡単です。大きさや配置する場所の条件によってはレイアウト自体も簡単になります。

4. ボタン自身のあたり判定領域を拡大

ここからはプログラマ的話です。透明ピクセルで誤魔化す方法がとれない場合、ボタンの見かけ上の大きさを変えずにボタンのあたり判定を大きくします。ここではボタン自身が自分のサイズよりもあたり判定領域を広くするアプローチを説明します。

方法は UIView- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event をオーバーライドします。pointInside:withEvent:- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event の中で実行されます。pointInside:withEvent:YES を返すとその View 自身にタップイベントが来ます。

まず、UIButton のサブクラスを作ります。pointInside:withEvent: メソッドを以下のような感じにオーバーライドします。

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
    CGRect rect = self.bounds;
    // 自身の bounds を Insets 分大きさを変える
    rect.origin.x += self.tappableInsets.left;
    rect.origin.y += self.tappableInsets.top;
    rect.size.width -= (self.tappableInsets.left + self.tappableInsets.right);
    rect.size.height -= (self.tappableInsets.top + self.tappableInsets.bottom);
    // 変更した rect に point が含まれるかどうかを返す
    return CGRectContainsPoint(rect, point);
}

tappableInsets はあたり判定を外から拡縮できるように追加したプロパティで型は UIEdgeInsets です。上下左右に当たり判定を 20pt 広がたい場合には

flexibleButton.tappableInsets = UIEdgeInsetsMake(-20, -20, -20, -20);

のような感じで指定します。

この方法ではボタン自身が当たり判定を広げているのでサブクラス化の手間はかかりますがボタンクラスの使い回しがしやすいのが利点です。

4. ボタンの親ビューがあたり判定を行う方法

わざわざボタンのサブクラス化が面倒な時且つボタン群を乗せた View のサブクラス(例えばおれおれツールバー風なもの)別途用意している時はこの方法が楽です。

ボタンを subview として持っている View クラスで - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event を以下のような感じでオーバーライドします。

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    // "hidden", "userInteractionEnabled", "alpha" の値を考慮する
    if (self.button.isHidden || self.button.userInteractionEnabled == NO || self.button.alpha < 0.01) {
        return [super hitTest:point withEvent:event];
    }
    
    // button のあたり判定 rect を作成
    CGRect rect = CGRectInset(self.button.frame, -20, -10);
    // あたり判定 rect 内であれば、button を返し、button にイベントを受けられるようにする
    if (CGRectContainsPoint(rect, point)) {
        return self.button;
    }
    
    // button のあたり判定外だったら従来の挙動に任せる
    return [super hitTest:point withEvent:event];
}

追記: リファレンスに記載されいてるように hidden, userInteractionEbabled, alpha の値を考慮する処理を追加しました。これが抜けていると hidden にしている button にもイベントが飛んでしまうためです。

5. 画像サイズはそのままでボタンを大きくする(追記)

例に挙げたアイコンを乗せただけのボタンの場合、setImage:forState: でアイコン画像をボタンにセットし、ボタンの frame を大きくすると見た目上(アイコン)の大きさを変えずにボタンの面積を大きくすることができます。比較的簡単に実装できます。

懸念点としては

  • 表現したいボタンによってはこの方法は使えない(見た目上も大きくなってしまう)
  • コードでレイアウトする場合に違和感がでる(origin がマイナスになるとか)

があるのであまり使わないかもしれません。

追記: タップされた位置が SuperView の外の場合

タップされた位置が button の superView の外の場合には上記の方法のいずれの方法もイベントがとれません。検証はしていませんが、button の superView 側の hitTest:withEvent: でイベントをフックしてやる必要がありそうです。

サブクラス化が面倒

紹介した2つの方法は何らかの View のサブクラスを作る必要があるため少々面倒です。そこでサブクラス化を行わない方法も少し紹介します。

REKit を使って任意のインスタンスメソッドをその場でオーバーライド

REKit という インスタンス毎の動的メソッド実装/上書き機能を備えた ライブラリを使ってみます。

以下のように、任意のインスタンスに REKit が提供する respondsToSelector:withKey:usingBlock: を使って pointInside:withEvent: をオーバーライドします。block の中の実装は先に紹介したものと同じです。

// REKit をつかって rekitButton のインスタンスに対してのみ `pointInside:withEvent:` をオーバーライド
[rekitButton respondsToSelector:@selector(pointInside:withEvent:)
                        withKey:nil
                     usingBlock:^BOOL(id receiver, CGPoint point, UIEvent *event) {
                         UIButton *button = (UIButton *)receiver;
                         CGRect rect = button.bounds;
                         rect.origin.x += -20;
                         rect.origin.y += -20;
                         rect.size.width -= (-20 + -20);
                         rect.size.height -= (-20 + -20);
                         return CGRectContainsPoint(rect, point);
                     }];

REKit は Runtime 関数等を使ってアグレッシブなことをしてくれるハイパーなやつです。場合によっては不具合もあるようですが頼もしいライブラリです。黒魔術的なものなのであまり使いすぎるのも少し怖い気がしますが今回の用途以外でも調査目的なんかにも使えそうなので覚えておくと便利だと思います。

サンプルコード

サンプルコードを一応おいておきます。