24/7 twenty-four seven

iOS/OS X application programing topics.

はやりのシンボルフォントを iOS で画像として扱える SymbolFontKit を公開しました。

kishikawakatsumi/SymbolFontKit · GitHub

ScreenShot1

↑ シミュレータに表示されている画像やツールバーのボタン、タブバーのアイコンは全てフォントです。

シンボルフォントとは要するにアイコン画像などをフォント形式にしたものです。
Webだと最近のブラウザだとWebフォントが使えるので、利用者の環境にフォントがインストールされていなくても使えるので、最近は解像度非依存ということもあっていろいろな Github や Twitter などいろいろなサイトで利用されています。

シンボルフォントについて詳しくは下記のリンク先などを見てください。
Ligature Symbols 〜ほんとにべんりなフォントのはなし〜
【完全版】Ligature Symbols フォントセットの自作方法 - くらげだらけ
Ligature Symbols

で、フォントなのでベクターデータのためどんな解像度でもキレイに表示されることや、1つのアイコンで色違いや別のサイズを表示することが簡単だったり、はやっているので様々なデザインのシンボルフォントが使いやすいライセンスで入手できるなど、iOS でも利用できたら便利だと思って作りました。

使い方

  1. SFKImage.h/m をプロジェクトにコピーします。
  2. 利用したいフォントファイルをプロジェクトにコピーします。
  3. 上記でコピーしたフォントファイルのファイル名を UIAppFonts をキーにして Info.plist に追加します。

API は UIImage 互換なので UIImage と同様の使い方ができます。(実は現在は imageNamed: 以外のインスタンス化はできません)

SFKImage *image = [SFKImage imageNamed:@"print"];


インスタンス化した SFKImage オブジェクトは UIImage のインスタンスと同様に UIButton や UIImageView、UITabBarItem などに直接設定することができます。

self.imageView1.image = [SFKImage imageNamed:@"share"];

UITabBarItem *calendarTabBarItem = [[UITabBarItem alloc] initWithTitle:@"calendar" image:[SFKImage imageNamed:@"calender"] tag:1];
UITabBarItem *globeTabBarItem = [[UITabBarItem alloc] initWithTitle:@"globe" image:[SFKImage imageNamed:@"globe"] tag:2];
_tabBar.items = @[calendarTabBarItem, globeTabBarItem];


フォントのレンダリングは実際に画面に描画されるときまで遅延されるので、UIImage と違って、1つのインスタンスを途中で色や大きさを変えたりできます。

SFKImage *image = [SFKImage imageNamed:@"compass"];
image.size = CGSizeMake(20, 20);
image.color = [UIColor redColor];
self.imageView6.image = image;
    
image.size = CGSizeMake(40, 40);
image.color = [UIColor yellowColor];
self.imageView7.image = image;
    
image.size = CGSizeMake(80, 80);
image.color = [UIColor blueColor];
self.imageView8.image = image;


UIImage として振る舞うためにちょっと無茶をしているのでそのまま AppStore の審査に通るかどうかは「?」ですが近いうちに適当なアプリを提出して調査したいと思います。


SymbolFontKit は第1回iphone_dev_jp東京 iPhone/Mac Hackathon 〜みんなが幸せになるハッカソン〜の成果物です。

はてなブックマークの ShareKit 拡張を書きました

ShareKit/Classes/ShareKit/Sharers/Services/Hatena at hatena · kishikawakatsumi/ShareKit · GitHub


ShareKit といういろいろな外部サービスとの連携機能を提供するライブラリがあるのですが、それのプラグイン (ShareKit では Sharer と呼びます) として「はてなブックマーク」とリンクを共有するものを書きました。
オリジナルを fork して hatena ブランチにコミットしています。利用するには clone して hatena ブランチに切り替えます。


本家にPull Request を送ったので、もしかしたらマージされるかもしれません。
(既存の Sharer に日本のサービスはなかったのでイマイチ勝手がわかりませんでした。)


↓ 発端は fladdict さんとの下記のやりとりです。


はてなブックマークへの投稿は昔書いたことがあったので、認証を OAuth にしたらあとはカンタンそうだなと思って安請け合いしました。いやまあ、特に難しいことはなかったんですけど。




↑ ちょっとだけハマったのは、付属している OAuth ライブラリ (OAuthConsumer) が不具合のあるリビジョンのもので、シグネチャを組み立てるときに追加の OAuth パラメータの部分を2重に URL エンコードしていました。
そのため、はてなの OAuth は必ずコールバック URL を oauth_callback パラメータで指定する必要があるのですが、シグネチャが invalid になって OAuth が成功しないということがありました。


↓ こちらの不具合は別の Pull Request でパッチを送ったので現在は修正されています。
Fix double URI encode for extra OAuth parameters. by kishikawakatsumi · Pull Request #515 · ShareKit/ShareKit · GitHub



↑ そんなこんなでとりあえず書いてみた割りにはちゃんと使えてるようでよかったです。
OAuth で許可するスコープに write_private を書いたら失敗する点とか気になるところはあるけど。。。



↑ Pull Request しました。↓
Add Hatena Bookmark Sharer. by kishikawakatsumi · Pull Request #521 · ShareKit/ShareKit · GitHub

第1回iphone_dev_jp東京 iPhone/Mac Hackathon 〜みんなが幸せになるハッカソン〜 を開催しました

iphone_dev_jp東京 iPhone/Mac Hackathon : ATND
iPhone_dev_jpで、みんなが幸せになるハッカソンを開催します | fladdict


前回の勉強会で深津さんが「一発もののアプリじゃなくてきちんと使われるライブラリをドキュメント込みで作るハッカソンやったらいいんじゃない?」って話をしていたので、それはすばらしいと思ったのでやってみました。


ハッカソンってやったことなくて、勝手がわからずにかなりギリギリの告知になってしまったのですが、約30名の猛者が集まってくださいました。

とはいえ、私は1日でそんなに書けるものだろうかと不安だったので実はそれまでの1週間である程度メドを立てておこうとか思ってたのですが、意外と時間がなくてぶっつけになってしまいました。
でもなんとかなるもので、やっぱり集中して書いたほうがダンゼン効率がいいんだなあとか今さらながら思いました。


こういう周辺ライブラリは必要だなあと思っててもけっこう普段の時間には書こうと思ってもなかなか筆が進まなかったりするもので、こういう集まりは小規模でもいいので定期的にやっていこうというところを確認できた一日でした。
かなりメリットのあることがわかったので近いうちにまたやると思いますのでまたみなさん集まってくれたらうれしいです。

第3回iphone_dev_jp 東京iPhone/Mac勉強会を開催しました

第3回iphone_dev_jp 東京iPhone/Mac勉強会 : ATND

発表内容と資料のまとめ

http://mtl.recruit.co.jp/blog/2012/07/721_iphone_dev_jp_iphonemacmtl.html
会場を提供してくださったMTLフナミさんのすばらしいまとめです。
すべての資料と発表ごとに分けていただいたUstreamの動画を見ることができます。


↓ Slidrsで共有した資料
第3回 iphone_dev_jp 東京iPhone/Mac勉強会 - Slidrs

ネット配信の録画

↓ ニコニコ動画(プレミアム会員のひとは1週間はタイムシフト視聴できます)
7/21 iPhoneDev-jp 勉強会放送 - 2012/07/21 13:00開始 - ニコニコ生放送

↓ Youtube
http://www.youtube.com/playlist?list=PL286E9699885DAEA8&feature=mh_lolz

CoreData の 属性に現在時刻など固定値以外のデフォルト値を設定する

CoreData のオブジェクトに作成日や更新時刻が自動的に入ったらいいなと思うことってありますよね。固定の値ならばモデルエディタの Defaut の欄に設定しておけば初期値として自動的に設定されますが実行時の現在時刻のように変わる値はモデルエディタでは設定することができません。

このような値をデフォルト値として設定するには NSManagedObject の awakeFromInsert メソッドを使用します。

#import "RecentSearch+CoreData.h"

@implementation RecentSearch(CoreData)

- (void)awakeFromInsert {
    [super awakeFromInsert];
    self.createdAt = [NSDate date];
    self.updatedAt = self.createdAt;
}

↑ このように awakeFromInsert メソッドをオーバライドしておくと、このオブジェクトが新しく作成されたときに自動的に現在時刻が設定されます。


↓ ちなみに、これを書いている途中に発見したのですが、モデルエディタの Default のところに "now" や "today" と入力すると現在時刻が「固定値として」設定されます。


あくまでここで設定できる初期値は固定値なのでイマイチ何の役にたつのか分かりませんが、もしかしたら便利なのかもしれません。

今年のチケット争奪戦が終わったので WWDC チェッカーのソースコードを公開します


kishikawakatsumi/WWDCChecker-Mac · GitHub
kishikawakatsumi/WWDCChecker-iPhone · GitHub


WWDC 2012 がようやく発表されました。
チケットは2時間ほどで売り切れてしまいましたが、なんとか買うことができました。

これまでの傾向から激しい争奪戦になることは分かっていたので WWDC のサイト (WWDC - Apple Developer) を監視して、更新があったら手元の iPhone にプッシュ通知で知らせてくれるアプリケーションを作りました。
今回、無事に役目を果たすことができたので少々の解説をしつつ、来年のためにソースコードを公開します。


↓ ぞくぞくと寄せられる喜びの声



構成は、通知を受けるための iPhone アプリケーションと、常時起動していてサイトを監視し変更があったら通知する Mac アプリケーション の2つのソフトウェアからなります。
↓ iPhone アプリはこんな感じです。

といってもこの画像ではわかりにくいですが、WWDC のサイトに自動的に接続するようにしてあるブラウザです。
別にプッシュ通知を受けるために必要なだけなので特に機能は必要ないのですが、プッシュ通知から起動したあと、すぐに購入することができるようにこうしてみました。

↓ 通知が届いたときの様子


プッシュ通知には Parse.com を使いました。
1,000,000 通知/月までは無料で使えるので今回のような Ad Hoc で限られた人にだけ配布するような用途にはピッタリです。
また Parse は iOS 用には専用の SDK が提供されているので決まったコードを少し書くだけでプッシュ通知が受けられるようになります。


↓ これだけのコードで通知を受けられるようになります。キモはプッシュ通知なのでインストールして通知が有効になってさえいれば、アプリケーションは起動しててもしてなくてもどうでもいいので、これだけでほぼ完成といえます。

@implementation WWDCAppDelegate

@synthesize window;
@synthesize viewController;

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
#if !(TARGET_IPHONE_SIMULATOR)
    TESTFLIGHT_TAKEOFF;
#endif
    
    [application registerForRemoteNotificationTypes:UIRemoteNotificationTypeSound | UIRemoteNotificationTypeAlert];
    
    [Parse setApplicationId:@"SCRdFC3GKlr9WD5ElC9IGpBZewLxDhrl2zSQOKlu" 
                  clientKey:@"5cRNScoCLbMlnPbgwznv40TxNRJKrVQGmXWwbCH5"];
    
    self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
    
    self.viewController = [[WWDCViewController alloc] initWithNibName:@"WWDCViewController" bundle:nil];
    window.rootViewController = viewController;
    [window makeKeyAndVisible];
    
    return YES;
}

- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)newDeviceToken 
{
    [PFPush storeDeviceToken:newDeviceToken];
    [PFPush subscribeToChannelInBackground:@""];
}

- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error 
{
    if ([error code] == 3010) {
        NSLog(@"%@", @"Push notifications don't work in the simulator!");
    }
    
    NSString *message = error.localizedDescription;
    
    NSBundle *bundle = [NSBundle mainBundle];
    NSDictionary *infoDictionary = [bundle localizedInfoDictionary];
    NSString *appName = [[infoDictionary count] ? infoDictionary : [bundle infoDictionary] objectForKey:@"CFBundleName"];
    
    UIAlertView *alert = [[UIAlertView alloc] initWithTitle:appName message:message delegate:nil cancelButtonTitle:nil otherButtonTitles:@"OK", nil];
    [alert show];
}

- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo 
{
    [PFPush handlePush:userInfo];
}

@end


Mac アプリケーションはいろいろ試しましたが、結局 WebView を使って一定の間隔でサイトにアクセスしてタイトルに変更がないかを比較する、という方式で監視することにしました。
HTTP リクエストを使ってダウンロードしたデータを比較したり、もっと厳密にやろうかと思ったのですがいろいろあってこの方法に落ち着きました。
これを家でつけっぱなしにしている Mac にインストールして準備完了です。


↓ Mac アプリのコードの抜粋です。Mac の WebView は自由にデータにアクセスできるから楽チンですね。Parse は SDK によるアクセスの他に REST API も用意されていて、プッシュ通知も REST API を使って iOS 以外から送信することができます。

- (void)onTimer:(NSTimer *)t
{
    if (!timer) {
        self.timer = [NSTimer timerWithTimeInterval:120.0 target:self selector:@selector(onTimer:) userInfo:nil repeats:YES];
        [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
    }
    
    NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"https://developer.apple.com/wwdc/"]];
    [request setHTTPShouldHandleCookies:NO];
    [request setCachePolicy:NSURLRequestReloadIgnoringLocalCacheData];
    
    [[webView mainFrame] loadRequest:request];
}

- (void)webView:(WebView *)sender didReceiveTitle:(NSString *)title forFrame:(WebFrame *)frame 
{
    if ([title isEqualToString:@"Apple Worldwide Developers Conference 2011"]) {
        [self log:@"WWDC Checked... no changes\n"];
    } else {
        [self notify];
    }
}

- (void)notify {
    [self log:@"WWDC 2012 may have been announced!\n"];
    
    NSString *JSON = @"{\"channel\":\"\", \"data\":{\"alert\":\"WWDC 2012 may have been announced!\", \"sound\":\"notify.wav\"}}";
    
    NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"https://api.parse.com/1/push"]];
    [request setHTTPMethod:@"POST"];
    [request addValue:@"ICFdOE4GOlu8WN5KlC0ILpASiqIxShrl6zWKQKlu" forHTTPHeaderField:@"X-Parse-Application-Id"];
    [request addValue:@"ork5uoWbcoYxV8wXz34WLhNfKzmyAGEKTOG4JDKF" forHTTPHeaderField:@"X-Parse-REST-API-Key"];
    [request addValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
    [request setHTTPBody:[JSON dataUsingEncoding:NSUTF8StringEncoding]];
    
    [NSURLConnection sendAsynchronousRequest:request queue:[NSOperationQueue mainQueue] completionHandler:^(NSURLResponse *response, NSData *data, NSError *error)
     {
         if (error) {
             [self log:error.localizedDescription];
         }
     }];
}


Mac アプリのほうも、最初はウインドウなど何もなかったのですが、ちゃんと動いているのか分からないと心配で監視アプリの死活を監視するためのアプリが必要になって……とキリがないのでウインドウにログ出力をするようにしました。
これで変更があったり、アプリが動かなくなっていることが一目でわかるようになりました。
↓ 以下は発表があった当時の様子です。2分間隔なので誤差はありますがこの記録によると 21:34:32 に発表があったことになります。

売り切れたのがだいたい 23 時半ですので今年は2時間もたなかったということになりますね。

というわけで来年はもはや発表前に売り切れるんじゃないかという話もある WWDC ですが、少しでも有利に争奪戦に参加するためにぜひ有効に活用してみてください。
(上記のコード、および GitHub のコードは Parse.com や TestFlight のトークンを書き換えてあります。試される際は自分のアカウントのトークンに直してご利用ください。)

CoreData の NSManagedObject のサブクラスを変更する場合はカテゴリを使うと便利

CoreDataのモデルクラスはXcodeのモデルエディタから自動生成しますが、生成されたクラスにメソッドを追加したりしたいことがあると思います。
そのとき、自動生成されたファイルを直接変更してしまうと、モデルに変更がありモデルクラスを再生成したときにその変更が上書きされてしまいます。

そこで、カテゴリを使って追加部分は別のファイルに分けておくと、モデルクラスを再生成しても後から追加した部分は上書きされずに残るのでそのまま使えます。


例えば下記のようなクラス (Event.h) があるとして、条件でフェッチするメソッドや、日付をフォーマットして返すメソッドを Event+CoreData.h/m や Event+Formatter.h/m として別ファイルに定義します。

// Event.h

#import <Foundation/Foundation.h>
#import <CoreData/CoreData.h>

@class Favorites, History, User;

@interface Event : NSManagedObject

@property (nonatomic, retain) NSNumber * accepted;
@property (nonatomic, retain) NSString * address;
@property (nonatomic, retain) NSString * catch;
@property (nonatomic, retain) NSDate * endedAt;
@property (nonatomic, retain) NSString * eventDescription;
@property (nonatomic, retain) NSNumber * eventID;
@property (nonatomic, retain) NSString * eventURL;
@property (nonatomic, retain) NSNumber * favorite;
〜(略)〜
// Event+CoreData.h

#import <Foundation/Foundation.h>
#import "Event.h"

@interface Event(CoreData)

+ (Event *)eventWithEventID:(NSNumber *)eventID;
+ (NSArray *)eventsWithStartDate:(NSDate *)startDate endDate:(NSDate *)endDate;

@end
// Event+CoreData.m

#import "Event+CoreData.h"
#import "EPCoreDataManager.h"

@implementation Event(CoreData)

+ (Event *)eventWithEventID:(NSNumber *)eventID {
    EPCoreDataManager *manager = [EPCoreDataManager sharedManager];
    NSManagedObjectContext *context = manager.managedObjectContext;
    
    NSPredicate *predicate = [self predicateWithEventID:eventID];
    
    NSFetchRequest *request = [[NSFetchRequest alloc] init];
    request.predicate = predicate;
    
    NSEntityDescription *entity = [NSEntityDescription entityForName:@"Event" inManagedObjectContext:context];
    request.entity = entity;
    
    NSArray *results = [context executeFetchRequest:request error:nil];
    
    Event *event = [results lastObject];
    if (!event) {
        event = [[Event alloc] initWithEntity:entity insertIntoManagedObjectContext:context];
        event.eventID = eventID;
    }
    
    return event;
}

+ (NSArray *)eventsWithStartDate:(NSDate *)startDate endDate:(NSDate *)endDate {
    EPCoreDataManager *manager = [EPCoreDataManager sharedManager];
    NSManagedObjectContext *context = manager.managedObjectContext;
    
    NSPredicate *predicate = [Event predicateWithStartDate:startDate endDate:endDate];
    
    NSFetchRequest *request = [[NSFetchRequest alloc] init];
    request.predicate = predicate;
    
    NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"startedAt" ascending:YES];
    request.sortDescriptors = [NSArray arrayWithObject:sortDescriptor];
    
    NSEntityDescription *entity = [NSEntityDescription entityForName:@"Event" inManagedObjectContext:context];
    request.entity = entity;
    
    NSArray *results = [context executeFetchRequest:request error:nil];
    
    return results;
}

@end
// Event+Formatter.h

#import <Foundation/Foundation.h>
#import "Event.h"

@interface Event(Formatter)

- (NSString *)formattedStartedAt;
- (NSString *)formattedEndedAt;
- (NSString *)formattedUpdatedAt;

@end
// Event+Formatter.m

#import "Event+Formatter.h"

@implementation Event(Formatter)

- (NSString *)formattedStartedAt {
    return [[self dateFormatter] stringFromDate:self.startedAt];
}

- (NSString *)formattedEndedAt {
    return [[self dateFormatter] stringFromDate:self.endedAt];
}

- (NSString *)formattedUpdatedAt {
    return [[self dateFormatter] stringFromDate:self.updatedAt];
}

- (NSDateFormatter *)dateFormatter {
    static NSDateFormatter *dateFormatter;
    static dispatch_once_t once;
    dispatch_once(&once, ^{
        dateFormatter = [[NSDateFormatter alloc] init];
        NSLocale *locale = [NSLocale currentLocale];
        NSArray *preferredLanguages = [NSLocale preferredLanguages];
        if ([preferredLanguages count] > 0) {
            locale = [[NSLocale alloc] initWithLocaleIdentifier:[preferredLanguages objectAtIndex:0]];
        }
        dateFormatter.locale = locale;
        dateFormatter.dateStyle = NSDateFormatterLongStyle;
        dateFormatter.timeStyle = NSDateFormatterShortStyle;
    });
    
    return dateFormatter;
}

@end

iphone_dev_jp 東京iPhone/Mac勉強会のあきすてさんの資料を共有しました



あきすてさんの依頼で私が代わりにSpeakerDeckで共有しました。
当日は話さなかった Unity Asset Server や Unity+Jenkins の話が載っていますのでぜひご覧ください。

iphone_dev_jp 東京iPhone/Mac勉強会 (@akisutesama) // Speaker Deck

iphone_dev_jp 東京iPhone/Mac勉強会を開催しました

iphone_dev_jp 東京iPhone/Mac勉強会 : ATND

ネット配信の録画(ニコニコ動画のプレミアム会員のひとは1週間はタイムシフト視聴できます)

4/14 iPhonedev-jp 勉強発表会 - 2012/04/14 13:00開始 - ニコニコ生放送


東京にいるのにもう長いことiOSの勉強会してない!名古屋とか大阪とか札幌のほうが活発にやってるなんておかしい!っていう感じのことをボードゲームの会のあとでごはんを食べているときに言ったら「じゃあ、やるかー」ということになって瀧内さん (@takiuchi) がサイバーエージェント・ベンチャーズのStartup Base Camp会議室というステキな場所をとってくれてその場で何人か発表してねってお願いしてなんだかんだで勉強会を主催することになりました。

そんそんさん(@sonson_twit)も書いているのですけど、僕もその場にいたメンバー+5〜10人くらいで考えていたので、あれよあれよというまに100人を超えて200人も参加希望があったときには正直どうしようかと思いました。

きっとその時点で僕がヘタレそうになっているということはお見通しだったのでしょう、しゃちさん(@shachi)をはじめ多くのひとが手伝ってくださったおかげでなんとか無事に開催することができました。
あらためて、ありがとうございました。

ただ、iOSの話っておもしろいことはひととおりやっちゃったよね、みたいな感覚があったのも確かで、たくさん来ていただいたものの期待に沿うような内容になるのかは正直半信半疑だったのですが、ふたを開けてみればそんな心配はまったく杞憂で、エクストリームiOSデベロップメントみたいな発表ばかり集まって非常に内容の濃い勉強会になったのでした。

本当にどの発表も楽しくて、それだけでこの会を開いてよかったなあと思いました。


また、今回はニコニコ生放送によるインターネット配信とフィードテイラーの大石さん(@oishi)のご好意によりSYNCNEL会議機能で資料を同期して閲覧する、ということが実現でき当日会場に来られなかったかたもリモートで参加することができ好評だったようです。

SYNCNELの会議機能を iPhone dev jp 勉強会で御利用頂きました! | 関西/大阪のiPhone・iPadアプリ開発 feedtailor Inc. 社長ブログ
ku-sukeのブログ
iPhonedev-jp 勉強発表会 – ニコニコ生放送を見たよ | Macで遊んでる
倍返しだ! : 『iphone_dev_jp iPhone勉強会を大阪で見る会』に行って来ました。





運営スタッフとしての反省は、事前に会場の下見に行っていればあと20人は参加人数の上限を増やすことができたかもしれないこと、ネットワークの準備と当日の仕切りをおざなりにしてしまったので最初の1〜2時間は安定してネット配信とスライドの同期が提供できなかったことが挙げられます。
非常にすばらしい発表をしてもらっていたのでキチッと準備していればちゃんとした形で最初からもっと多くのひとに聞いてもらうことができたかなあと思っています。


ということで不手際はありましたが、心からやってよかったと思いますし次回も近いうちに開催したいと思ってます。
なので発表をお願いしたら快く引き受けてくださいね!と誰ともなく言ってみる。


あとバッファとして用意していた私の発表はけっきょくしなかったので、近いうちに補足して記事にします。


http://shachi.hatenablog.com/entry/2012/04/14/220813
sonson.jp
ハイスピードXcodeコーディング – iphone_dev_jp東京勉強会 | DOTAPON Blog
MacでかんたんKinect - よぴったんこ
A-Liaison BLOG: iphone_dev_jp 東京iPhone/Mac勉強会でしゃべってきた & 反省
【参加してみた】iOSDevJp勉強会 | RE:START
SYNCNELの会議機能を iPhone dev jp 勉強会で御利用頂きました! | 関西/大阪のiPhone・iPadアプリ開発 feedtailor Inc. 社長ブログ
ku-sukeのブログ
iPhonedev-jp 勉強発表会 – ニコニコ生放送を見たよ | Macで遊んでる
倍返しだ! : 『iphone_dev_jp iPhone勉強会を大阪で見る会』に行って来ました。
iOS/MacOS 開発勉強会 - 情報 -> 知識 -> 経験 の足跡
【おもいっきり!iOS!】iphone_dev_jp 東京iPhone/Mac勉強会まとめ #idevjp - ぬんびりぶろぐ
☆iPhoneアプリ開発者のみなさまへ☆プロモコードでプロモーション!「PromoBook」#idevjp - ぬんびりぶろぐ
わたしを見つけて!アプリのApp Store内のSEO最適化 #idevjp - ぬんびりぶろぐ

当日のタイムテーブル

時間 発表者 タイトル
13:00 – 13:10 @takiuchiさん 会場についての諸注意・説明

(トイレの場所、無線LANの設定、自販機の使い方など)
13:10 – 13:35 @5mingame2さん C++とOpenGL ES 1.1の話
13:40 – 14:05 @kamiyanさん ibisPaint の OpenGL ES 2.0
14:10 – 14:35 @cocoponさん キーボードで完結!ハイスピードXcodeコーディング
14:40- 15:05 @nakamura001さん Unity恐くないよ!!
休憩 (10分)
15:15 – 15:40 @yopita_さん Mac でかんたん Kinect
15:45 – 16:10 @akisutesamaさん ・Unity Asset Server使ってみた

・Unity Asset Server+Jenkins

・Unity Cache Server

・Core Animation

・Blocks

・NSURLConnection
16:15 – 16:40 @novi_さん CoreText のはなし
16:45 – 17:10 @sonson_twitさん iCloud
休憩 (10分)
Lightening Talks
17:20 – @monsukeさん プロモコード配布サービス PromoBook について
@umekun123さん App Store内のSEO最適化について調べてみた
@k_katsumi WWDC Checkerの作りかた(Parse.com でらくらくサーバーサイド)

画像キャッシュとダウンローダで参考になる(と思う)オープンソースの実装


ダウンロードした画像をキャッシュするクラスの設計と実装について - 24/7 twenty-four seven

実は上の記事で紹介したクラスを書いた当時、それが必要だったアプリをリリースしたすぐ後くらいにほとんど同じ実装のライブラリを見つけまして。
自分の実装はそんなに間違ってなかったんだなーと安心しつつも、はじめからこれを使っとけばよかったとも思ったので紹介します。

rs/SDWebImage · GitHub


NSCacheは使ってないですが、その他は私が書いたものとよく似ていて、たぶんオーソドックスな実装なんじゃないかなと思います。


SDImageCache と SDWebImageDownloader がライブラリのコアとなるクラスで、これらとうまく連携するように UIImageView や UIButton の拡張や、サポートクラスがあるという構成になっています。

パッと見た感じ、けっこう構成ファイルが多いように見えますが、それぞれのクラスはかなり疎結合になっているのでキャッシュだけ、ダウンローダだけを利用することも簡単です。


前回の記事の、画像が無い場合にデフォルト画像を返す引数はキャッシュのクラスには無いですが、UIImageView のカテゴリで、 - (void)setImageWithURL:(NSURL *)url placeholderImage:(UIImage *)placeholder; というメソッドがあるので、やっぱり無かったらプレースホルダとしてデフォルト画像を返せるようにする、というのはよくある要件なのだなあと思いました。


この SDWebImageCarousel という Instagram を Macで閲覧できるアプリケーションでも使われています。

ダウンロードした画像をキャッシュするクラスの設計と実装について

iOS組み込みのキャッシュモジュールNSCacheについて発表しました - ninjinkun's diary


上のような感じでせっかく話を振ってもらったので私がいつも使ってるキャッシュの実装を公開してみます。
だいたい誰が書いてもこんな感じになると思っているのですが、こうしたら便利だよ、とか、それはおかしいとか突っ込んでもらえるとうれしいです。


インターフェースと実装はだいたい下記のようになります。

#import <Foundation/Foundation.h>

typedef void (^ImageCacheResultBlock)(UIImage *image, NSError *error);

@interface ImageCache : NSObject

+ (ImageCache *)sharedInstance;

- (UIImage *)imageWithURL:(NSString *)URL
                    block:(ImageResultBlock)block;
- (UIImage *)imageWithURL:(NSString *)URL 
             defaultImage:(UIImage *)defaultImage 
                    block:(ImageResultBlock)block;

- (void)clearMemoryCache;
- (void)deleteAllCacheFiles;

@end
〜(略)〜

- (UIImage *)imageWithURL:(NSString *)URL 
                    block:(ImageResultBlock)block {    
    return [self imageWithURL:URL defaultImage:nil block:block];
}

- (UIImage *)imageWithURL:(NSString *)URL 
             defaultImage:(UIImage *)defaultImage 
                    block:(ImageResultBlock)block {    
    if (!URL) {
        return defaultImage;
    }
    
    UIImage *cachedImage = [self cachedImageWithURL:URL];
    if (cachedImage) {
        return cachedImage;
    } else {
        cachedImage = defaultImage;
    }
    
    __block ASIHTTPRequest *request = [ASIHTTPRequest requestWithURL:[NSURL URLWithString:URL]];
    
    [request setCompletionBlock:^{
        NSData *data = [request responseData];
        UIImage *image = [UIImage imageWithData:data];
        if (image) {
            [self storeImage:image data:data URL:URL];
            block(image, nil);
        } else {
            block(nil, [NSError errorWithDomain:@"ImageCacheErrorDomain" code:0 userInfo:nil]);
        }
    }];
    [request setFailedBlock:^{
        block(nil, request.error);
    }];
    
    [networkQueue addOperation:request];
    
    return cachedImage;
}

設計の勘所

この例だと、キャッシュとダウンローダが一体になっていて、キャッシュに無かったら自動的にダウンロードしますが、場合に応じてダウンローダは別にすることもあります。

だいたい、以下の3パターンをプロジェクトに応じて使い分けています。


使い分けの目安としては、画像以外にもAPIアクセスがある場合(たいていはそうでしょうけど)は別にして、APIのアクセスを含むネットワークのアクセスを別のクラスにまとめてしまって、キャッシュクラスではネットワークアクセスは行わないようにするほうがスッキリすることが多いですね。


または、テーブルビューに表示する場合は、スクロール中にはダウンロードを実行したくないとか、画面に表示されている行の画像を先にダウンロードしたいとか、細かく挙動を制御したくなることがけっこうありますので、キャッシュからの取得とネットワークからの取得がコントロールできるように分けたほうがうまくいきます。


まあ、よっぽど小規模のアプリケーションでなければ、ダウンローダとキャッシュは分けたほうが融通が利いていいと思います。

2番目と3番目の違いは、キャッシュクラスに直接アクセスするかどうかです。キャッシュクラスに直接アクセスせずにダウンローダやAPIアクセスのクラスを通してのみ画像を取得するようにすると、コードがシンプルになるのですが、先に述べたようにダウンロードのタイミングを細かく制御する場合は3番目の設計にすることが多いです。


使い方は下のようになります。

UIImage *cachedImage = 
 [[ImageCache sharedInstance] imageWithURL:photoURL 
                                     block:^(UIImage *image, NSError *error)
 {
     cell.photo = image;
 }];

cell.photo = cachedImage;


たいていはこんなにシンプルにはならなくて実際は下のようになることが多いです。
キャッシュに無くてスクロール中やドラッグ中でない場合のみネットワークからダウンロードします。

UIImage *cachedImage = 
 [[ImageCache sharedInstance] cachedImageWithURL:photoURL];
if (!cachedImage) {
    if (!tableView.dragging && !tableView.decelerating) {
        [ImageDownloader downloaderWithURL:photoURL
                                  delegate:self 
                                  userInfo:[NSDictionary dictionaryWithObject:indexPath forKey:@"indexPath"]];
    }
}

cell.photo = cachedImage;

実装のTips

デフォルト画像をパラメータで指定できるようにしておく

キャッシュクラスあるいはAPIクラスの画像取得のメソッドには、デフォルト画像として無ければそれ自身をそのまま返すパラメータを用意しておくと便利です。
どう便利かというと、ユーザーアイコンなどで画像が未ダウンロード、あるいは設定されてないときにデフォルトアイコンを表示する、という要件はよくあると思いますが、それをシンプルに実装することができます。

UIImage *cachedIconImage = 
 [[ImageCache sharedInstance] imageWithURL:userIconURL 
                              defaultImage:[UIImage imageNamed:@"defaultIcon.png"]
                                     block:^(UIImage *image, NSError *error)
 {
     cell.userIcon = image;
 }];

cell.userIcon = cachedIconImage;
NSCacheを使う

キャッシュはディスクキャッシュをメインで使いつつ、メモリキャッシュと併用しますが、メモリキャッシュにはNSCacheを使うのが便利です。

NSCacheについては冒頭でも引用した下記の記事が詳しいです。
iOS組み込みのキャッシュモジュールNSCacheについて発表しました - ninjinkun's diary


簡単にいうとNSCacheはキャッシュ用に便利は機能が追加されたNSMutableDictionaryです。
格納できるオブジェクト数の上限や容量の上限を決めて超えたぶんは自動的に削除されるとか、自動削除のタイミングで処理を実行することができるなどです。


私は数の上限だけを設定して、容量の制限は使いません。
サムネイル画像やアイコン画像とメイン画像で容量が全然違うよ、っていう場合はNSCacheのインスタンスを複数使ってそれぞれに上限を設定して使い分けます。

下記は、NSCacheを複数使い分ける例です。

cache = [[NSCache alloc] init];
cache.countLimit = 20;

thumbnailCache = [[NSCache alloc] init];
thumbnailCache.countLimit = 100;
ディスクキャッシュのファイル名はMD5ハッシュ値を使う

ダウンロードした画像データをディスクに保存するときのファイル名ですが、ダウンロード先のURLはスラッシュ"/"やコロン":"がパスの文字列としてジャマになったり、日本語が含まれていたりと面倒なのでURLからMD5ハッシュ値を計算して、それをファイル名に使います。

ついでに1つのディレクトリに大量のファイルを入れると速度低下が心配なので適当にバラけるようにハッシュ値の最初の2文字を使ってサブディレクトリに小分けします。


実装は次のようになります。

+ (NSString *)keyForURL:(NSString *)URL {
	if ([URL length] == 0) {
		return nil;
	}
	const char *cStr = [URL UTF8String];
	unsigned char result[16];
	CC_MD5(cStr, (CC_LONG)strlen(cStr), result);
	return [NSString stringWithFormat:@"%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X",
            result[0], result[1], result[2], result[3], result[4], result[5], result[6], result[7],result[8], result[9], result[10], result[11],result[12], result[13], result[14], result[15]]; 	
}

- (NSString *)pathForKey:(NSString *)key {
    NSString *path = [NSString stringWithFormat:@"%@/%@/%@", cacheDirectory, [key substringToIndex:2], key];
    return path;
}

- (void)storeImage:(UIImage *)image data:(NSData *)data URL:(NSString *)URL {
    NSString *key = [KDImageCache keyForURL:URL];
    [cache setObject:image forKey:key];
    
    [data writeToFile:[self pathForKey:key] atomically:NO];
}


以上です。
今回用いたサンプルの完全なコードを下に掲載しておきます。
ツッコミ歓迎です。

#import "ImageCache.h"
#import "ASIHTTPRequest.h"
#import <CommonCrypto/CommonHMAC.h>

@interface ImageCache() {
    NSFileManager *fileManager;
    NSString *cacheDirectory;
    
    NSCache *cache;
    
    NSOperationQueue *networkQueue;
}

@end

@implementation ImageCache

+ (ImageCache *)sharedInstance {
    static ImageCache *sharedInstance;
    static dispatch_once_t pred;
    dispatch_once(&pred, ^{
        sharedInstance = [[ImageCache alloc] init];
    });
    
    return sharedInstance;
}

- (id)init {
    self = [super init];
    if (self) {
        [[NSNotificationCenter defaultCenter] 
          addObserver:self 
             selector:@selector(didReceiveMemoryWarning:)
                 name:UIApplicationDidReceiveMemoryWarningNotification 
               object:nil];
        
        cache = [[NSCache alloc] init];
        cache.countLimit = 20;
        
        fileManager = [[NSFileManager alloc] init];
        
        NSArray *paths = 
         NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
        cacheDirectory = [[[paths lastObject] 
                            stringByAppendingPathComponent:@"Images"] retain];
        
        [self createDirectories];
        
        networkQueue = [[NSOperationQueue alloc] init];
        [networkQueue setMaxConcurrentOperationCount:1];
    }
    return self;
}

- (void)dealloc {
    [[NSNotificationCenter defaultCenter] removeObserver:self];
    [fileManager release];
    [cacheDirectory release];
    [cache release];
    [networkQueue release];
    [super dealloc];
}

- (void)didReceiveMemoryWarning:(NSNotification *)notif {
    [self clearMemoryCache];
}

- (void)createDirectories {
    BOOL isDirectory = NO;
    BOOL exists = [fileManager fileExistsAtPath:cacheDirectory 
                                    isDirectory:&isDirectory];
    if (!exists || !isDirectory) {
        [fileManager createDirectoryAtPath:cacheDirectory 
               withIntermediateDirectories:YES 
                                attributes:nil 
                                     error:nil];
    }
    for (int i = 0; i < 16; i++) {
        for (int j = 0; j < 16; j++) {
            NSString *subDir = 
             [NSString stringWithFormat:@"%@/%X%X", cacheDirectory, i, j];
            BOOL isDir = NO;
            BOOL existsSubDir = 
             [fileManager fileExistsAtPath:subDir isDirectory:&isDir];
            if (!existsSubDir || !isDir) {
                [fileManager createDirectoryAtPath:subDir 
                       withIntermediateDirectories:YES 
                                        attributes:nil 
                                             error:nil];
            }
        }
    }
}

#pragma mark -

+ (NSString *)keyForURL:(NSString *)URL {
	if ([URL length] == 0) {
		return nil;
	}
	const char *cStr = [URL UTF8String];
	unsigned char result[16];
	CC_MD5(cStr, (CC_LONG)strlen(cStr), result);
	return [NSString stringWithFormat:@"%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X",
            result[0], result[1], result[2], result[3], result[4], result[5], result[6], result[7],
            result[8], result[9], result[10], result[11],result[12], result[13], result[14], result[15]]; 	
}

- (NSString *)pathForKey:(NSString *)key {
    NSString *path = [NSString stringWithFormat:@"%@/%@/%@", cacheDirectory, [key substringToIndex:2], key];
    return path;
}

#pragma mark -

- (UIImage *)cachedImageWithURL:(NSString *)URL {
    NSString *key = [ImageCache keyForURL:URL];
    UIImage *cachedImage = [cache objectForKey:key];
    if (cachedImage) {
        return cachedImage;
    }
    
    cachedImage = [UIImage imageWithContentsOfFile:[self pathForKey:key]];
    if (cachedImage) {
        [cache setObject:cachedImage forKey:key];
    }
    
    return cachedImage;
}

#pragma mark -

- (void)storeImage:(UIImage *)image data:(NSData *)data URL:(NSString *)URL {
    NSString *key = [ImageCache keyForURL:URL];
    [cache setObject:image forKey:key];
    
    [data writeToFile:[self pathForKey:key] atomically:NO];
}

- (void)clearMemoryCache {
    [cache removeAllObjects];
}

- (void)deleteAllCacheFiles {
    [cache removeAllObjects];
    
    if ([fileManager fileExistsAtPath:cacheDirectory]) {
        if ([fileManager removeItemAtPath:cacheDirectory error:nil]) {
            [self createDirectories];
        }
    }
    
    BOOL isDirectory = NO;
    BOOL exists = [fileManager fileExistsAtPath:cacheDirectory isDirectory:&isDirectory];
    if (!exists || !isDirectory) {
        [fileManager createDirectoryAtPath:cacheDirectory 
               withIntermediateDirectories:YES 
                                attributes:nil 
                                     error:nil];
    }
}

#pragma mark -

- (UIImage *)imageWithURL:(NSString *)URL 
                    block:(ImageResultBlock)block {    
    return [self imageWithURL:URL defaultImage:nil block:block];
}

- (UIImage *)imageWithURL:(NSString *)URL 
             defaultImage:(UIImage *)defaultImage 
                    block:(ImageResultBlock)block {    
    if (!URL) {
        return defaultImage;
    }
    
    UIImage *cachedImage = [self cachedImageWithURL:URL];
    if (cachedImage) {
        return cachedImage;
    }
    
    __block ASIHTTPRequest *request = [ASIHTTPRequest requestWithURL:[NSURL URLWithString:URL]];
    
    [request setCompletionBlock:^{
        NSData *data = [request responseData];
        UIImage *image = [UIImage imageWithData:data];
        if (image) {
            [self storeImage:image data:data URL:URL];
            block(image, nil);
        } else {
            block(nil, [NSError errorWithDomain:@"ImageCacheErrorDomain" code:0 userInfo:nil]);
        }
    }];
    [request setFailedBlock:^{
        block(nil, request.error);
    }];
    
    [networkQueue addOperation:request];
    
    return defaultImage;
}

@end

iOS 5.1 ではスクロールビューをスクロールするたびに少しずつメモリリークが起こる。

iOS 5.1 では UIScrollViewにバグがあるようで慣性スクロールが終わるタイミングで少しずつメモリがリークしてしまうようです。
UIScrollView を継承している UITableView や UITextView も同じ不具合が発生します。
内部に UIScrollView を保持している UIWebView も同様です。


Xcode 4.3.1 の Single View Application に下記のコードを追加しただけのアプリケーションと Instruments の Leaks を使用して検証しました。

- (void)viewDidLoad
{
    [super viewDidLoad];

    UIScrollView *scrollView = [[UIScrollView alloc] initWithFrame:CGRectMake(0.0f, 0.0f, 320.0f, 460.0f)];
    scrollView.contentSize = CGSizeMake(320.0f, 920.0f);
    [self.view addSubview:scrollView];
    [scrollView release];
}

iOS 5.1 ではシミュレータでも iPhone でも iPad でもメモリリークが発生しました。
5.0.1 や 4.3.5 ではシミュレータ、デバイスとも再現しませんでした。


アップルの Developer Forums にもこの問題についての書き込みがありました。

Developer Forums: Elements App Memory Leak


アプリケーション側で対処できることは無さそうなので私たちにできることはバグレポートを送ってできる限り早く修正してもらうことですね。

Apple Bug Reporter

音声を使ってハンズフリーでアプリケーションを操作するサンプル

iOS 5.1 からプログラムからも利用できるようになった音声入力を使ってアプリケーションを操作するサンプルを公開しました。

kishikawakatsumi/VoiceNavigation · GitHub




このサンプルでは「〜を検索」と話すとその言葉で Google 検索を実行します。
「戻る」「進む」と話すことでブラウザの「戻る/進む」を実行できます。


一定の間隔で自動的に音声入力の開始/終了を繰り返すことで常に入力を受け取れるようにしています。
開始/終了を任意のタイミングで実行することもできるので何らかの合図(カメラや振動)によって開始するようにしてもいいかもしれません。


認識精度が優れているのでパターンマッチを増やすだけでもいろいろなことができておもしろいのではないでしょうか。
ぜひいろいろ試してみてください。


※読めばわかると思いますが、音声入力の開始と終了に非公開の API を使用していますので、残念ながらそのまま AppStore に申請することはできません。
※iPad で使ったらダンゼン便利、と思ってユニバーサルアプリとして作ってますが iPad の UI はまだ書いてません。
※なぜかバックグラウンドから戻ってきたあと動作が怪しくなります。

iOS 5.1 の音声入力を使ってアプリケーションを操作してみる

iOS 5.1 から日本語の音声入力がサポートされました。さらに API にも UIDictationPhrase など音声認識ができそうなクラスが追加されています。
これはプログラムから音声認識を使うことができるのか?そうすると音声を使ってハンズフリーでアプリケーションの操作ができるかと思いましたが調べてみるとそんなにうまい話ではありませんでした。


まず iOS 5.1 で追加された関係のありそうな API を見てみましょう。

UITextInput.h
  • Added UIDictationPhrase
  • Added UIDictationPhrase.alternativeInterpretations
  • Added UIDictationPhrase.text
  • Added -[UITextInput dictationRecognitionFailed]
  • Added -[UITextInput dictationRecordingDidEnd]
  • Added -[UITextInput insertDictationResult:]


UIDictationPhrase といかにも便利そうな名前のクラスですが、リファレンスをよく読んでみると text プロパティと alternativeInterpretations プロパティ以外はメソッドもなく、単に UITextInput protocol の insertDictationResult: の引数に結果を詰めるだけのクラスということがわかります。

さらに読み進めていくと追加された API はすべて UITextInput protocol のメソッドであり要するに音声入力はあくまでもテキスト入力の一環で、 UITextInput protocol を通して入力を受け取ることしかできないということがわかります。

つまり手順としては

  1. UITextInput protocol を実装して
  2. ファーストレスポンダになりキーボードを出して
  3. キーボードの音声入力ボタンで録音を開始して
  4. 完了ボタンで録音した音声をサーバに送り
  5. 結果を UITextInput protocol のメソッドを通して受け取る

ということになります。


キーボードは自動で表示するとしても、入力の開始と終了でキーボードのボタンを手で押す必要があるというのは今回の目的においてはあまり便利ではありません。

そこでどうにか自動的に入力を開始/終了することができないか調べてみたところ、UIDictationController というクラスにそれっぽいメソッドがありました。

@interface UIDictationController
〜(略)〜
+ (id)sharedInstance;
+ (id)activeInstance;
- (void)stopDictation;
- (void)cancelDictation;
- (void)startDictation;
- (void)startRecordingLimitTimer;
- (void)cancelRecordingLimitTimer;
〜(略)〜
@end

実験してみると、startDictation, stopDictation, cancelDictation メソッドでそれぞれ、開始・終了・キャンセルを実行できることがわかりましたので今回はこれを利用することにしました。
公開されていないクラスのため、このままでは AppStore に申請することはできませんが、今回は実験ということで。


ということでだいたい実現できそうな気になってきたので以下のように実装してみました。
まず、なにはなくとも UITextInput protocol を実装します。 UITextInput protocol は UIKeyInput protocol を継承しているのでそちらのメソッドも実装します。
カスタムのテキストビューを作るためにテキスト入力システムとやりとりするものなので実装しなければいけないメソッドがかなりたくさんありますが、今回はテキスト入力をまじめにハンドリングする必要はないので、ほとんど空の実装でオーケーです。

@interface VNTextInputView : UIView<UITextInput, UIGestureRecognizerDelegate>

@end

#pragma mark UIKeyInput methods

- (void)deleteBackward {
    
}

〜(略)〜

#pragma mark UITextInput methods

- (NSString *)textInRange:(UITextRange *)range {
    return nil;
}

〜(略)〜

#pragma mark -

- (void)insertDictationResult:(NSArray *)dictationResult {
    [[NSNotificationCenter defaultCenter] postNotificationName:VNDictationRecognitionSucceededNotification
                                                        object:self 
                                                      userInfo:[NSDictionary dictionaryWithObject:dictationResult forKey:VNDictationResultKey]];
}

- (void)dictationRecordingDidEnd {
    [[NSNotificationCenter defaultCenter] postNotificationName:VNDictationRecordingDidEndNotification object:self];
}

- (void)dictationRecognitionFailed {
    [[NSNotificationCenter defaultCenter] postNotificationName:VNDictationRecognitionFailedNotification object:self];
}

@end


iOS 5.1 で追加された3つのメソッドで音声入力をハンドリングします。
音声入力ボタンを押す、あるいは startDictation メソッドで開始したあと、完了ボタン、あるいは stopDictation メソッドが呼ばれたタイミングで dictationRecordingDidEnd が呼ばれます。
そして自動的にサーバに録音された音声データが送られます。
サーバの処理が完了した段階で、何らかのテキストが認識された場合は insertDictationResult: が、音声認識ができなかった場合は dictationRecognitionFailed のいずれかが呼ばれます。

結果は文字列として UIDictationPhrase の text プロパティに格納されており、1つの音声データに対して複数の候補がある場合は alternativeInterpretations プロパティに配列で格納されて受け取れます。UIDictationPhrase はある程度のまとまりごとに配列になっています。


これで任意のタイミングで音声入力を利用できるようになりました。
認識の精度はかなり高いのでパターンマッチを工夫するだけでも実用になりそうです。
使い道としてはハンズフリーでもアプリケーションの操作や、別のプログラムを呼び出したり、家電のリモコンとして使ってもおもしろそうです。


ここまでを簡単な形に整理して GitHub で公開しています。
ぜひ試して改良してみてください。

kishikawakatsumi/VoiceNavigation · GitHub