24/7 twenty-four seven

iOS/OS X application programing topics.

CoreTextを使って簡単に画像付きリッチテキストを表示できるSECoreTextViewに編集機能がつきました。

kishikawakatsumi/SECoreTextView · GitHub
iOS/Macの両方で使えて、文字の選択やリンクのクリックに対応したテキストビューをテスト公開しました。 - 24/7 twenty-four seven



前に書いたSECoreTextViewに編集機能を実装しました (iOSのみ)。


SECoreTextViewはCoreTextを使って簡単にクリッカブルなリンクや画像付きのリッチテキストを表示できるテキストビューの代替実装としてのライブラリです。


以前のものはそこそこ簡単に豊かな表現ができるのでこれはこれでけっこう実用的だったと思います。
↓ このように画像を含めたテキストを表示したり、リンクはクリックに反応して任意の処理をすることができます。
画像に限らず、画面に表示できるものはボタンでもその他のビューでもブロックを渡して任意の描画をすることも可能です。


iOS ScreenShot 1 iOS ScreenShot 1


そんな感じで、表示のみなら標準のUITextViewやUIWebViewをがんばって使うよりは柔軟で取り回しやすいのでけっこう便利に使っていたのですが、だんだん表示だけでは物足りなくなってきたので編集できるようにしてみました。

iOS ScreenShot 1 20130927032539

これまでの実装に加えて、UITextInput/UIKeyInput Protocol を実装して、標準のテキストビューと同じようにキーボードや日本語変換システムの入力を処理しています。


たいていの画面に表示できるものは扱えるので、わりと万能なテキスト編集コンポーネントになったんじゃないかなとおもいます。
だいたいの動きがわかるムービーを作ったのでこちらもどうぞ。
SECoreTextView Demo on Vimeo


UITextInputの実装やCoreTextはいろいろおもしろかったので、技術的なところはまた機会をみて書こうと思います。
今後はパフォーマンス・チューニングやOS Xのほうの実装などを予定しています。あ、それと音声入力への対応ですね。


メッセージアプリやブログエディタなどに、応用がきいて使いやすいと思いますので、いろいろ実戦投入していただけるとうれしいです。
今のうちなら何かあったらけっこうすぐに私が対応できると思います。よろしくお願いします。

複雑な正規表現を分かりやすくするライブラリ VerbalExpressions の Objective-Cバージョンを書きました

https://github.com/VerbalExpressions/ObjectiveCVerbalExpressions
↑ 本家にマージされました。

https://github.com/kishikawakatsumi/ObjectiveCVerbalExpressions

概要

VerbalExpressions はメソッドチェーンとわかりやすい名前を使って、正規表現を読みやすくしようという試みです。
↓ オリジナルはJavaScriptのライブラリのようです。
https://github.com/VerbalExpressions/JSVerbalExpressions


iOS Dev Weeklyの106号でObjective-Cの移植はまだ無いみたいに書いてあったので、やってみました。
(実際は2つほど先に書かれたものがありました)

↓ 私が書いた Objective-C 版のライブラリを使うと下記のように記述できます。

// Create an example of how to test for correctly formed URLs
VerbalExpressions *verEx = [VerbalExpressions instantiate:^(VerbalExpressions *ve) {
    ve.startOfLine(YES)
    .then(@"http")
    .maybe(@"s")
    .then(@"://")
    .maybe(@"www")
    .anythingBut(@" ")
    .endOfLine(YES);
}];

最初のインスタンス化は Blocks 付きのメソッドを使わずに普通にインスタンス化することもできます。
(alloc] init] や new を使ってもいいでしょう)

VerbalExpressions *verEx = [VerbalExpressions expressions];
verEx.startOfLine(YES).then(@"http").maybe(@"s").then(@"://").maybe(@"www").anythingBut(@" ").endOfLine(YES);

仕組みについて

通常 Objective-C とこういったメソッドチェーンを多用する、いわゆる「流れるようなインターフェース (fluent interface)」はあまり相性がよくありません。

例えば、別の人の書かれた Objective-C 版の VerbalExpressions ですが、普通にメソッドチェーンを使って書くと下記のようになります。
https://github.com/sakiwei/ObjectiveCVerbalExpressions

// url matches
VerbalExpressions *tester = [[[[[[[VerEX() startOfLine] then:@"http"] maybe:@"s"] then:@"://"] maybe:@"www."] anythingBut:@" "] endOfLine];

↑ 読みやすくないこともないですが、書きやすくはないですよね。
普通の Objective-C のメソッド呼び出しは両側にカッコを追加して行かなければならないので、チェーンを追加しようとすると最初に戻って開きカッコを追加したりしないといけないのでこのやり方だと、考えながら書くっていうのが難しいです。


なので今回は Blocks を利用して他のライブラリと同様にドットでチェーンできるようにしてみました。

VerbalExpressions *verEx = [VerbalExpressions expressions];
verEx.startOfLine(YES).then(@"http").maybe(@"s").then(@"://").maybe(@"www").anythingBut(@" ").endOfLine(YES);


方法としては VerbalExpressions クラスに自分自身を戻り値として返すブロックをプロパティとして定義しています。

@interface VerbalExpressions : NSObject

@property (nonatomic, readonly) VerbalExpressions *(^startOfLine)(BOOL enable);
@property (nonatomic, readonly) VerbalExpressions *(^endOfLine)(BOOL enable);
@property (nonatomic, readonly) VerbalExpressions *(^find)(NSString *value);
@property (nonatomic, readonly) VerbalExpressions *(^then)(NSString *value);
@property (nonatomic, readonly) VerbalExpressions *(^maybe)(NSString *value);
@property (nonatomic, readonly) VerbalExpressions *(^anything)();
@property (nonatomic, readonly) VerbalExpressions *(^anythingBut)(NSString *value);


プロパティに実装は下記のようになっていて、プロパティを参照するとブロックが実行されて正規表現が組み立てられるというしくみです。そしてこのブロックは自分自身のインスタンスを返すので、その戻り値に対してドットでチェーンできる、というように書かれています。

- (VerbalExpressions *(^)(NSString *))maybe
{
    return ^VerbalExpressions *(NSString *value) {
        value = [self sanitize:value];
        self.add([NSString stringWithFormat:@"(%@)?", value]);
        return self;
    };
}


この方法の課題としてはオーバーロードができないので、例えばオリジナルの JS ライブラリでは `startOfLine()` と `startOfLine(bool)` という 2 つのメソッドがあるのですが、Blocks のプロパティだとどちらも同じ名前になってしまうので、引数付きのものだけ用意されています。


実際に有用かどうかは使いどころによると思いますが、おもしろい試みだと思いますので、ぜひ使ってみてください。
バグレポートや Pull Request もお待ちしています。

iOS/Macの両方で使えて、文字の選択やリンクのクリックに対応したテキストビューをテスト公開しました。

kishikawakatsumi/SECoreTextView · GitHub

iOS ScreenShot 1


OS X ScreenShot 1


SECoreTextView はリッチテキストの表示と文字の選択(現在はOS Xのみ)やリンクがクリック可能だったりするテキストビューです。
別のアプリでテーブルビューのセルにリンクを含むテキストを表示するのに、既存のものでMacで使えるいい感じのものが今ひとつ見つからなかったので書きました。

OS X で使うだけだとなんなので、せっかくだから iOS にも対応してみました。

UITableVIewやNSTableVIewのセルで使うと便利だと思います。

iOS のほうは半日くらいでちょちょっと書いただけなのでおかしなところが結構あると思うので見つけたら教えてください。

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

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


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


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

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


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

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

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


ダウンロードした画像をキャッシュするクラスの設計と実装について - 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

Xcode 4.3ではファイルをドラッグ&ドロップで追加するとき、ターゲットに追加するかどうかのチェックが外れることが多いので注意!

バージョン 4.2.x までの Xcode では、プロジェクトにファイルをドラッグ&ドロップして追加するとき、コピーするかどうかや、ターゲットに追加するかどうかのチェックボックスは前回の選択状態を引き継ぐという挙動でした。
しかし、バージョン 4.3 になってからはターゲットに追加するかどうかのチェックボックスについてのみ、追加しようとしているファイルの拡張子によって、選択状態が変化するようになりました。

ざっと調べた限りでは、追加しようとするファイルが .m ファイルだけの場合はチェックが選択済みになり、.h が含まれる場合は選択が解除されているようです。


具体的には下記のように JSONKit.h と JSONKit.m を追加しようとすると、.h ファイルが含まれているために選択状態は解除された状態でダイアログが表示されます。



もし、選択状態が解除されていることに気づかずに、そのままプロジェクトに追加してしまうと、プロジェクトには追加されるもののコンパイル対象には含まれないという状態になってしまいます。


この状態は非常に危険で、ヘッダファイルは参照できるためにコンパイル時に警告は出ないし、Objective−Cの場合はたいていリンクもできてしまいます。特に例のJSONKitのような標準クラスをカテゴリで拡張するようなライブラリだとまずビルド時に問題は起こりません。


もちろんコンパイルされないのでオブジェクトができませんから実行時にエラーになりますが、この「プロジェクトに追加されているけれどターゲットに含まれていない」という状態は分かりにくいので問題の解決が遅れることも多々あります。


というわけで、けっこう何も考えずにリターンを押しがちなところだと思いますが、少し注意する必要があるという話でした。


※Xcode 4.3.1 は今ダウンロード中なのでアップデートが済んだら検証します。
Xcode 4.3.1 でも同じ挙動でしたorz... ファイル追加するたびに神経使うのもアレなので、要望としてバグレポートですね。






twitter:177334116054863873:tree

ColorChooser が便利


カラーピッカーで選択した色を自動的に UIColor や NSColor のコードに変換してくれるユーティリティです。
同様のものに Developer Color Picker がありますが、 それのメニュー常駐版のような感じです。
コードを書いているときにどこからでも呼び出せるので私はこちらのほうが便利だなと思います。

ColorChooser - Pairote Leelaphattarakij

ネットワークの通信速度を制限する Preference Pane "SpeedLimit"

mschrag@github


SpeedLimit は Mac のネットワーク通信速度に制限をかけることができる Preference Pane です。

上限のプリセット値として 1572k (T1), 768k (DSL), 384k (3G), 64k (Edge), and 48k (Dialup) の5種類が用意されています。

上限値を選択して "Slow Down" ボタンを押すと、通信速度が制限値まで遅くなります。

ネットワークを使用した iPhone アプリケーションのテストをシミュレータ上で実行するときに便利です。

対象となるホストを指定することができるので、iPhone アプリケーションの接続先のみ制限して、他の Mac の通信速度はそのまま、ということもできるようになっています。

delegate オブジェクトは retain すべきではない

Delegating objects do not (and should not) retain their delegates. However, clients of delegating objects (applications, usually) are responsible for ensuring that their delegates are around to receive delegation messages. To do this, they may have to retain the delegate in memory-managed code. This precaution applies equally to data sources, notification observers, and targets of action messages. Note that in a garbage-collection environment, the reference to the delegate is strong because the retain-cycle problem does not apply.

Mac Developer Library

delegate オブジェクトはたいていの場合 retain メッセージを送信して保持してはいけません。


なぜなら、普通の使い方では delegate に代入されるオブジェクトはほとんどのケースで委譲元を保持 (retain) しているからです。
その状態で delegate オブジェクトを保持 (retain) してしまうと、循環参照 (retain cycle) に陥ってしまうので、どちらのオブジェクトも解放することができず、メモリリークが発生してしまいます。


例えば UITableView の場合を考えます。
普通、ビューコントローラが UITableView のインスタンスを生成し、デリゲートにはビューコントローラ自身を指定します。

- (void)loadView {
    UIView *contentView = [[UIView alloc] initWithFrame:CGRectMake(0.0f, 0.0f, 320.0f, 367.0f)];
    contentView.backgroundColor = [UIColor whiteColor];
    self.view = contentView;
    [contentView release];
    
    tableView = [[UITableView alloc] initWithFrame:contentView.frame style:UITableViewStylePlain];
    tableView.delegate = self;
    tableView.dataSource = self;
    [contentView addSubview: tableView];
    [tableView release];
}

このとき、 UITableView のインスタンスがデリゲート・オブジェクトを保持 (retain) するならば、テーブルビューとビューコントローラが互いの参照を保持しあう形になるので、どちらも解放することができなくなります。


上記の問題を避けるため、デリゲート・オブジェクトは通常 assign 属性として宣言します。

@property(nonatomic, assign) id delegate


Cocoa のクラスではほとんどのデリゲート・プロパティは assign 属性として宣言されていますが、わずかに例外があります。
ひとつは CAAnimation クラスのデリゲート・プロパティです。

@property(retain) id delegate

Important: The delegate object is retained by the receiver. This is a rare exception to the memory management rules described in Memory Management Programming Guide for Cocoa.
An instance of CAAnimation should not be set as a delegate of itself. Doing so (outside of a garbage-collected environment) will cause retain cycles.

Mac Developer Library

ドキュメントにも記述されていますが、稀な例外 (a rare exception) のひとつです。


他の例としては NSURLConnection に指定したデリゲートも NSURLConnection のインスタンスによって保持されます。

Xcode のプロジェクト名を変更するには

プロジェクト>名称変更... でいいらしい。

プロジェクトファイル名、ターゲット名、プリコンパイル済みヘッダ、Info.plist を一括で変更してくれます。
プロダクト名や、リンクマップファイルへのパス、ビルドディレクトリなど各種パスも、一度閉じて開き直すときちんと変更されていました。

リファレンスや定義を簡単に引ける Xcode のテクニック

Xcode で Option キー + メソッドやプロパティをダブルクリック、の機能が便利だったので、似たようなショートカットを調べてみました。

Option キー + ダブルクリック

対象のシンボルのリファレンスがポップアップする(便利!)

Command キー + ダブルクリック

対象のシンボルの定義部分に飛ぶ

Command キー + Option キー + ダブルクリック

デベロッパドキュメント(クラスリファレンスなど)を参照