ネットワーク上の画像表示の実装
今回は特に新しいトピックではないのですが、最近、身近でネットワーク上の遅延画像表示をどう実装するのがいいのかという話題が出たので書いてみました。アプリでネットワークを介して画像を表示するケースが多々あると思いますが、ベストプラクティスを考えると意外に難しいと思います。実際にSoichaを実装した際に苦労した点の1つにタイムライン上の大量のプロフィール画像の表示があります。単純にURL先のデータを取得して描画するだけならNSData#dataWithContentsOfURLを使えば簡単なのですが画面がロックされるキャンセルできないなど色々と問題があったりします。また、大量の画像データがある場合は通信帯域が限られているスマートフォンでは色々な工夫が必要になります。
画像表示でおおよそ出てくる要件としては下記のようなものです。
要件
- 非同期でデータを読み込みたい
- 同時通信数を3つに制限したい
- 画像データ取得を途中でキャンセルしたい
- 同じ画像データは再度通信しないようにキャッシュしたい
- 取得した画像データは永続化したい
- UITableViewやのCell上でも画像を表示したい
この要件を満たすアプリに組み込むとビジネスロジック以外の部分でそれなりに煩雑なロジックになってきます。今後、幸せになれるようにUIImageViewを拡張したIMCachedImageViewクラスとメモリ保持用のシングルトンインスタンスのIMImageManagerクラスのみで完結するように実装してみました。
IMCachedImageView.h
IMCachedImageView.m
CachedImage.xcdatamodeld
非同期通信の実装
非同期での画像データの取得方法は、
- NSData#dataWithContentsOfURL: を非同期キューで読み込む
- NSURLConnection#connectionWithRequest:delegate:で読み込む
- NSURLConnection#sendAsynchronousRequest:queue:completionHandler で読み込む
など色々あるわけですが、
要件2のキューを制限することと要件3のキャンセルの要件を満たすために、非同期キューはNSOperationQueueを使い、キャンセル可能なようにNSURLConnection#connectionWithRequest:delegate を使用します。
NSOperationを継承したクラスで並列実行モードの実装を行い、startメソッド内でNSURLConnectionを発行します。注意点としてNSURLConnectionは通常メインスレッドでしか動かないので、NSOperation#start内のループではRunLoopを回しておく必要があります。(iOS4以降)
- (BOOL)isConcurrent { return YES; } - (void)start { if (self.isCancelled) { [self finish]; return; } [self setValue:[NSNumber numberWithBool:YES] forKey:kObservingExecuting]; _request = [NSURLRequest requestWithURL:[NSURL URLWithString:_url] cachePolicy:NSURLRequestUseProtocolCachePolicy timeoutInterval:TIMEOUT_INTERVAL]; _connection = [NSURLConnection connectionWithRequest:_request delegate:self]; if (_connection != nil) { do { if (self.isCancelled) { [_connection cancel]; [self finish]; } [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]; } while (_isExecuting); } }
GCDやNSURLConnection#sendAsynchronousRequestなど色々と便利になったわけですが、大量アクセスがある場合は、やはりNSOperationQueue+NSURLConnectionのdelegateを使う方が小回りが効きそうです。
NSOperationの並列実行モードとNSURLConnectionについてはこちらの記事が参考になります。
http://d.hatena.ne.jp/glass-_-onion/20110706/1309909082
データ取得後はNSNotificationでIMCachedImageViewに通知を受信してレンダリングするようにします。
データキャッシュ機構
要件5の永続化はファイルで保存すると1000ファイルを超えたくらいからアクセスが一気に遅くなった経験があります。今回はインデックスが使えるようにSqliteを利用しようと思います。CoreDataはurlをキーにしてインデックスを貼るようにします。デフォルトのOptionalのチェックを外してIndexedにチェックを入れます。
キャッシュ機構は一度読み込んだデータはメモリ上とSqliteに保持するようにして、画像データロード時にはメモリ>DB>リクエスト発行とコストの低い順にアクセスするようにします。
具体的にはstaticなオブジェクトにNSDictionaryを持たせ、URL自体をユニークなキーとして扱いUIImageを保持するようにします。メモリへのアクセスは一瞬なのでメインスレッドで行いますが、DBアクセス、ネットワークアクセスは非同期スレッドで行います。
非同期取得データのレンダリング
要件6は意外と厄介です。
UITableViewはCellを再利用する機構があるため、上下にスクロールして画面から消えたCellのオブジェクトは別の行で再利用され、その行のデータソースで上書きされるようになります。行が表示されるタイミングでデータが存在していれば問題は発生しないのですが、非同期通信でデータ受信後に描画する際、どのオブジェクトに対してレンダリングするのかわからなくなるためです。それを回避するためにユニークなキーURLをデータソースに持たせ、拡張した全部のUIImageViewにデータ取得の通知を行い一致したキーのオブジェクトに対してのみレンダリングさせるようにします。
- (void)observingUpdateImage:(NSNotification *)notification { NSString *userInfoUrl = notification.userInfo[kUserInfoUrl]; UIImage *userInfoImage = notification.userInfo[kUserInfoImage]; if ([_url isEqualToString:userInfoUrl]) { __block __weak IMCachedImageView *weakSelf = self; dispatch_async(dispatch_get_main_queue(), ^{ [weakSelf updateImage:userInfoImage]; [weakSelf hideIndicator]; }); } }
IMCachedImageViewの使い方
CoreDataを使うのでCoreData.frameworkをLink Binary with Librariesから追加します。
対象のソースは下記3つのみです。
- IMCachedImageView.h
- IMCachedImageView.m
- CachedImage.xcdatamodeld
使い方は単純にIMCachedImageViewにdefaultImage、errorImage、urlをセットするだけです。
IMCachedImageView *cachedImageView = [[IMCachedImageView alloc] initWithFrame:(CGRect){10, 10, 68, 68}]; cachedImageView.defaultImage = [UIImage imageNamed:@"DefaultImage"]; cachedImageView.errorImage = [UIImage imageNamed:@"ErrorImage"]; cachedImageView.url = @"http://www.st-hatena.com/users/h_/h_mori/user.jpg"; [self addSubview:cachedImageView];
urlをセットした段階でメモリ上、DB上に存在しない場合は勝手にリクエストキューにプールされます。defaultImageはローディング中に表示する画像、errorImageは画像が取得できなかった場合に表示する画像、username、passwordはダイジェスト認証時に使用するプロパティです。
IMCachedImageViewが破棄されリクエストが不要になった場合、キャッシュも不要の場合はIMImageManager#cancelAllOperationを実行します。
[[IMImageManager sharedManager] cancelAllOperation];
Sqliteの画像データをtruncateする場合はIMImageManager#truncateStoreを実行します。
[[IMImageManager sharedManager] truncateStore];
実装サンプルコード
https://github.com/hmori/CachedImageSample
Google画像検索APIから特定のキーワード画像URLを60件程度取得してUITableViewに遅延表示・キャッシュ・画像データを永続化するサンプルです。UITableViewCellにIMCachedImageViewを配置し、Cellが表示されたタイミングでメモリ上、DB上を参照し存在しない場合にリクエストのキューに順次スタックする実装です。画面が破棄された場合などを想定してリクエストキューのキャンセル、SqliteのTruncate、メモリの破棄もサンプルで実装しています。
本サンプルコードの著作権は行使しないのでご自由にお使いください。これで皆さんが幸せになれるのであれば幸いです。尚、サンプル上のLoadボタンのGoogle画像検索APIは非推奨APIなのでご注意ください。
EvernoteAPI連携を試してみる
先日、Evernote Devcup 2013 Meetup 東北にて開発者枠にて登壇させて頂きました。Evernote APIは3年前にSOICHAでEvernote連携機能の実装以来なので、再度、復習がてらクラウドAPIを使った簡単なiOSのサンプルアプリを一から作ってみました。以前はsliftだったので自分でSDKの出力する形式だったのですが、最近はiOS専用にメンテナンスされているようなので使い勝手は良くなってました。ドキュメントもきちんと日本語で整備されているので、ノート作成程度であれば初めての実装でも1日かからないと思います。
EvernoteクラウドAPI実装手順
本番サーバへのアクセスにはアクティベーションが必要で2、3日程度かかるので、その間にsandbox環境にて実装します。尚、sandbox環境のアカウントとデータは本番サーバとは独立していて連動していないようです。
1. sandbox環境のログインアカウントを作成
開発にはsandboxを利用するのでここで新規アカウントを作成します。
http://sandbox.evernote.com/Registration.action
2. コンシューマキーの取得
EvernoteAPIはOAuth認証を使うのでここでコンシューマキーを取得します。
http://dev.evernote.com/intl/jp/documentation/cloud/
右上の「API KEY」から、使用するアプリケーション毎に取得します。
尚、ここで登録したアプリケーション名はOAuth認証時にユーザに表示されます。
API権限について
ベーシックアクセス:ノート、ノートブック、タグの新規作成、既存データの読込
フルアクセス:全APIのアクセス権限
※フルアクセスを利用する場合はアクティベーション時に理由の説明が必須になります。
3. SDK、必要なFrameworkの組込
GithubからEvernoteSDKをダウンロードし、Xcodeに追加します。
https://github.com/evernote/evernote-sdk-ios
Project Build PhasesのLink Binary With Librariesより「Security.framework」「StoreKit.framework」を追加します。
info.plistにURL schemeを追加します。URL SchemesのItemには「en-{ConsumerKey}」と記述します。
4. アプリ内へのEvernote APIの実装
AppDelegateのHeader
#import "EvernoteSession.h" #import "ENConstants.h"
AppDelegate#didFinishLaunchingWithOptions
NSString *EVERNOTE_HOST = BootstrapServerBaseURLStringSandbox; NSString *CONSUMER_KEY = @"your-key"; NSString *CONSUMER_SECRET = @"your-secret"; [EvernoteSession setSharedSessionHost:EVERNOTE_HOST consumerKey:CONSUMER_KEY consumerSecret:CONSUMER_SECRET];
セッションの確立時
EvernoteSession *session = [EvernoteSession sharedSession]; [session authenticateWithViewController:self completionHandler:^(NSError *error) { if (!error && session.isAuthenticated) { EvernoteUserStore *userStore = [EvernoteUserStore userStore]; [userStore getUserWithSuccess:^(EDAMUser *user) { NSLog(@"success evernote session"); } failure:^(NSError *error) {} ]; } }];
ノート作成時
#import "EvernoteSession.h" #import "EvernoteUserStore.h" #import "EvernoteNoteStore.h"
static NSString *enmlTemplate = @"<?xml version=\"1.0\" encoding=\"UTF-8\"?><!DOCTYPE en-note SYSTEM \"http://xml.evernote.com/pub/enml2.dtd\"><en-note>%@</en-note>"; EDAMNote *note = [[EDAMNote alloc] init]; note.title = @"Title"; note.tagNames = [NSArray arrayWithObject:@"Tag"]; note.content = [NSString stringWithFormat:enmlTemplate, @"note contents"]; [[EvernoteNoteStore noteStore] createNote:note success:^(EDAMNote *note) { [[[UIAlertView alloc] initWithTitle:@"Complete" message:@"Success created note." delegate:nil cancelButtonTitle:@"Close" otherButtonTitles:nil, nil] show]; } failure:^(NSError *error) {}];
EDAMNote#contentはENMLという独自のマークアップ言語を使います。
http://dev.evernote.com/intl/jp/start/core/enml.php
一部のHTML要素は使えないようなのでWebページをそのままクリッピングする場合はタグを変換してあげる必要がありそうです。
5. アクティベーション申請
sandbox上でノート作成の確認が取れたら、取得したコンシューマキーを本番サーバでもアクセスできるようにアクティベーション申請を行います。
http://dev.evernote.com/intl/jp/start/
Evernoteには日本人もいますので日本語でも大丈夫だとは思いますが、急いでいるのであれば英語で申請した方が早く承認されるかもしれません。
6. 本番サーバ接続
アクティベーションされた後は、AppDelegateのEVERNOTE_HOSTをBootstrapServerBaseURLStringUSに変更して本番サーバにてテストします。
OAuth認証
EvernoteSession#authenticateを実行した段階で認証が行われていない場合(有効期限が切れている場合)は、SDKによりWebのログイン画面が自動で表示されます。認証後、Evernoteサイトの設定のアプリケーションのタブに認証済みアプリが表示されるので、ここで認証の取り消しが行えます。
サンプルアプリ
行動ログをEvernoteに取るというアプリを想定して、現在地の地図とその記録時間をEvrenoteに書き込んでいくというiOSのサンプルを書いてみました。
地図の作成は緯度経度を元に、GoogleMapsのStaticMapでURLを作成してENMLに埋め込んでいます。また、住所の割り出しにはiOS SDKのCoreLocationのgeocorderを利用しています。
Githubにソースコードを上げました。Xcodeにてビルドすればそのまま動かせると思います。
https://github.com/hmori/LocationHistory
発表資料
Evernote Devcup 2013 Meetup 東北で発表した資料です。
開発者視点ですが、Evernote連携はリソースをユーザーの領域を使うので、データ量の肥大化や負荷が集中した場合にEvrenoteサービスの利用料を心配する必要がありません。例えばEvernoteユーザが前提のアプリであれば、システムストレージをEvrenoteに置くなんて設計も可能です。共有とか文字認識などの機能も備えているのでそれを利用した面白いサービスは考えられそうですね。
SWWDCでネタ発表してきました
今年初のSWWDCに参加してきました。
http://atnd.org/events/36588
全員がいくつかネタを持ち寄ってゆるゆると発表する形式だったのですが、色々な方面のネタがあってなかなか濃い内容でした。私が紹介したのは、RemotePushNotificationASPサービス、MagicalRecord、小ネタ集の3つです。
LT形式のネタは準備の負担が少なくていいですね。毎度ながらにSWWDCは本当に色々と勉強になります。
RESTfullな汎用APIサーバjsonengineで超簡単にBBSを作ってみる
iOS5よりNSJSONSerializationという標準で扱えるクラスが追加され、Objective-CとJSONは非常に相性がよくなった気がします。CoreDataも構造型ストレージ感が強いのでなんでもJSON-Object変換でという流れになりそうな予感です。サーバ側も昨今ではGraph APIを筆頭に段々とNoSQL的なリソースを直に扱う平たいAPIが増えてきました。それに伴いサーバ側のロジックは簡素化していきクライアント側のロジックが複雑化するという流れが予想されるわけですが、その是非はさておき、今回はその流行りに乗った超汎用RESTfulなGoogle App Engineモジュールのjsonengineを紹介します。
jsonengineとは
http://code.google.com/p/jsonengine/
jsonengineとはGoogle App Engine上で動作する汎用的なJSONストレージを実現したJavaモジュールで、ストレージにBigTableが利用されているのでスケーラビリティは非常に高いです。
これの何が便利かというと、Java・Pythonなどの開発が一切不要(Eclipse等の開発ツールも不要)、AppEngine上にデプロイして管理画面で設定するだけでREST APIによってJSONストレージとして利用できます。GET/POST/DELETE/PUTのHTTPメソッドを使って、JSONドキュメントを直接操作するので、オンラインでDBを使っている感覚です。また、一旦デプロイしてしまえば開発中サーバ側を弄ることがありません。
10分もかからずにサーバ設置が可能なのでオンライン上のデータを扱うアプリのサーバプロトタイプに非常に有効だと思います。
jsonengineサーバ構築
AppEngineを初めて使う場合は、AppEngineのサイトからサインアップしてください。ここでは既にGoogle App Engineのサインアップが済んでいる状態として進めます。
AppEngine上にアプリケーションを作成
AppEngineのアプリケーション管理画面で「Create Application」からApplication Identifierを登録します。
Identifierはアクセスする際のURLに含まれます。※) http://{identifier}.appspot.com/
今回、私は"hmorijsonenginetest"で作っています。
Google App Engine SDK for Javaのダウンロード
Google App EngineのダウンロードサイトからSDKをダウンロードします。
※2012/11/25時点では、ver1.7.3が最新
ダウンロードしたらPathにスペースが含まれない任意の作業フォルダに解凍して"appengine-java-sdk-dir"とリネームします。
jsonengineのダウンロード
jsonengineのサイトからリリースモジュールをダウンロードします。
ダウンロードしたらPathにスペースが含まれない任意の作業フォルダに解凍して"jsonengine-dir"とリネームします。
デプロイ
jsonengine-dir/war/WEB-INF/appengine-web.xmlをテキストエディタで開き、aplicationタグをAppEngineで作成したApplication Identifierに書き換えます。
threadsafeタグをtrueで追加します。※threadsafeタグが必須になった模様
appengine-web.xml
<!-- Generated from app.yaml. Do not edit. --> <appengine-web-app xmlns='http://appengine.google.com/ns/1.0'> <application>hmorijsonenginetest</application> <version>2010-11-06</version> <static-files> </static-files> <resource-files> </resource-files> <ssl-enabled>true</ssl-enabled> <precompilation-enabled>true</precompilation-enabled> <sessions-enabled>false</sessions-enabled> <admin-console> <page name='jsonengine Admin' url='/_admin/'/> <page name='jsonengine BBS Sample' url='/samples/bbs.html'/> </admin-console> <threadsafe>true</threadsafe> </appengine-web-app>
ターミナルよりappcfg.shを使ってデプロイします。(※Windowsの場合はappcfg.cmdを使用)
./appengine-java-sdk-dir/bin/appcfg.sh update jsonengine-dir/war
Googleアカウントのemailとパスワードを入力して、"Update completed successfully."となればデプロイ完了です。
hmori-mbp:jetest hmori$ ./appengine-java-sdk-dir/bin/appcfg.sh update jsonengine-dir/war/ Reading application configuration data... 2012/11/25 17:26:51 com.google.apphosting.utils.config.AppEngineWebXmlReader readAppEngineWebXml ???: Successfully processed jsonengine-dir/war/WEB-INF/appengine-web.xml 2012/11/25 17:26:51 com.google.apphosting.utils.config.AbstractConfigXmlReader readConfigXml ???: Successfully processed jsonengine-dir/war/WEB-INF/web.xml 2012/11/25 17:26:51 com.google.apphosting.utils.config.AbstractConfigXmlReader readConfigXml ???: Successfully processed jsonengine-dir/war/WEB-INF/queue.xml 2012/11/25 17:26:51 com.google.apphosting.utils.config.IndexesXmlReader readConfigXml ???: Successfully processed jsonengine-dir/war/WEB-INF/datastore-indexes.xml Beginning server interaction for hmorijsonenginetest... Email: hmori99@gmail.com Password for hmori99@gmail.com: 0% Created staging directory at: '/var/folders/9q/gr5hbvpd4g96ryps5tcv92900000gn/T/appcfg1338647429479990427.tmp' 5% Scanning for jsp files. 8% Compiling jsp files. 2012/11/25 17:27:13 com.google.apphosting.utils.config.AbstractConfigXmlReader readConfigXml ???: Successfully processed /var/folders/9q/gr5hbvpd4g96ryps5tcv92900000gn/T/appcfg1338647429479990427.tmp/WEB-INF/web.xml 20% Scanning files on local disk. 25% Initiating update. 28% Cloning 68 static files. 31% Cloning 155 application files. 40% Uploading 0 files. 52% Initializing precompilation... 90% Deploying new version. 95% Will check again in 1 seconds. 98% Will check again in 2 seconds. 99% Will check again in 4 seconds. 99% Will check again in 8 seconds. 99% Closing update: new version is ready to start serving. 99% Uploading index definitions. 99% Uploading task queues. Update completed successfully. Success. Cleaning up temporary files...
http://{myappid}.appspot.com/samples/bbs.html で正常に動作しているか確認ができます。
iOSアプリ
今回は簡易BBSアプリみたいなのを作ってみます。
↓こんな感じの
- 初期表示時及び更新ボタン押下時に最新20件のrecordを取得しTableViewに反映
- postボタン押下時に1件のrecordを作成、完了時に最新20件を再取得しTableViewに反映
- Editにて行削除時に対象recordのdocIdをキーに削除リクエスト発行しTableViewに反映
- searchボタン押下時にuserに入力された文字列でrecordを取得しTableViewに反映
recordの構造は{"user":"名前", "msg","メッセージ本文"}とします。idや更新メタ情報は自動的にjsonengineで付加されます。
付加される項目
- _docId: 各ドキュメントに振られる一意なID
- _createdAt: ドキュメントが生成された時点のタイムスタンプ(long型)
- _createdBy: ドキュメントを生成したユーザのユーザID
- _updatedAt: ドキュメントが更新された時点のタイムスタンプ(long型)
- _updatedBy: ドキュメントを更新したユーザのユーザID
発行するAPI
GET /_je/records?sort=_createdAt.desc&limit=20
最新で20件の取得(初期表示時、更新ボタン時など)
GET /_je/records?cond=user.eq.'input_user'
userが指定値に一致するrecordを取得(search押下時)
POST /_je/records BODY: _doc={'user':'input_user', 'msg':'input_msg'}
入力値で新規recordを作成(post押下時)
DELETE /_je/records/<docId>
指定docIdのrecordを削除(行の削除時)
制限事項
Google App Engineの制限によるものですが、いくつかの念頭に置いておく必要があります。
・1ドキュメントのサイズは1MB以下
・1プロパティのサイズは500文字以下 (インデックス制限)
・sort条件または非等価演算子(不等号)を使う場合、同時に2つ以上プロパティの条件は指定できない
公式ドキュメント
http://code.google.com/p/jsonengine/wiki/HowToUse
http://code.google.com/p/jsonengine/wiki/HowToUseQuery
日本語訳ドキュメント
http://jxck.bitbucket.org/jsonengine-doc-ja/build/html/HowToUse.html
http://jxck.bitbucket.org/jsonengine-doc-ja/build/html/HowToUseQuery.html
ソースコード
Request発行部
static NSString * const urlData = @"https://hmorijsonenginetest.appspot.com/_je/records"; - (void)requestGetUser:(NSString *)user { NSMutableString *url = [NSMutableString stringWithString:urlData]; [url appendString:@"?"]; if (user && user.length > 0) { [url appendString:@"cond="]; [url appendString:[[NSString stringWithFormat:@"user.eq.%@", user] urlencode]]; } else { [url appendString:@"sort=_createdAt.desc&limit=20"]; } NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]]; [request setHTTPMethod:@"GET"]; __block __weak JEViewController *weakSelf = self; [NSURLConnection sendAsynchronousRequest:request queue:[NSOperationQueue mainQueue] completionHandler:^(NSURLResponse *response, NSData *data, NSError *errer) { weakSelf.records = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers error:nil]; [weakSelf.tableView reloadData]; }]; } - (void)requestPostUser:(NSString *)user msg:(NSString *)msg { NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:urlData]]; [request setHTTPMethod:@"POST"]; NSString *postString = [NSString stringWithFormat:@"_doc={'user':'%@', 'msg':'%@'}", self.userTextField.text, self.msgTextField.text]; NSData *postData = [postString dataUsingEncoding:NSUTF8StringEncoding]; [request setHTTPBody:postData]; __block __weak JEViewController *weakSelf = self; [NSURLConnection sendAsynchronousRequest:request queue:[NSOperationQueue mainQueue] completionHandler:^(NSURLResponse *response, NSData *data, NSError *error) { [weakSelf requestGetUser:nil]; }]; } - (void)requestDeleteDocId:(NSString *)docId { NSMutableString *url = [NSMutableString stringWithFormat:@"%@/%@" ,urlData, docId]; NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]]; [request setHTTPMethod:@"DELETE"]; __block __weak JEViewController *weakSelf = self; [NSURLConnection sendAsynchronousRequest:request queue:[NSOperationQueue mainQueue] completionHandler:^(NSURLResponse *response, NSData *data, NSError *errer) { [weakSelf requestGetUser:nil]; }]; }
初期化処理
- (void)viewDidLoad { [super viewDidLoad]; [self requestGetUser:nil]; }
UITableViewDataSource
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return _records.count; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell"]; NSDictionary *rec = [_records objectAtIndex:indexPath.row]; NSString *user = [rec objectForKey:@"user"]; NSString *msg = [rec objectForKey:@"msg"]; cell.textLabel.text = user; cell.detailTextLabel.text = msg; return cell; } - (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath { if (editingStyle == UITableViewCellEditingStyleDelete) { NSDictionary *rec = [_records objectAtIndex:indexPath.row]; [self requestDeleteDocId:[rec objectForKey:@"_docId"]]; [_records removeObjectAtIndex:indexPath.row]; [self.tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade]; } }
問題点
今回、実験したdocTypeの権限レベルはpublicなのでデータがフルアクセス可能な状態です。尚、jsonengineの認証方法はGoogleアカウントかOpenIDをサポートしているようですがiOSからは使い勝手が悪いので、もしアクセス制限をかけるとするならjsonengine自体にBASIC認証をかけてしまった方が手っ取り早いかもしれません。
実際にはjsonengineは制限が強いこともありプロトタイプ用として考えるべきと思います。とはいえこのお手軽さは非常に便利なのでオススメです。
サンプル
今回作成したiOSのソースをGithubにアップしました。
https://github.com/hmori/JsonEngineTest
Remote Push Notification ASPサービスを試す
iOSのリモートプッシュ通知はアプリを起動していなくても能動的にユーザーに対する情報が送れるため、サービスへの動線を確保する上で非常に有効です。アプリのバージョンアップの通知に利用するだけでもユーザー離れを防ぐことができます。
リモートプッシュ機能を実装するためには、通知送出のプロバイダ(サーバ)を用意する必要があります。
最低限必要となるプロバイダの機能は下記の3つ。
- ユーザーのiOSのデバイストークンを受け取り管理する
- 管理しているデバイストークンに対して通知したい情報を送出する
- APNS(Apple Push Notification Service)からフィードバックを受け取る
一応、apns-php等がNew BSD Licenseで公開されていたりするので自前で作らなくともよいのですが、アプリのバージョンアップ通知のためだけに設置するのもちょっと大げさな気がします。
http://code.google.com/p/apns-php/
Remote PushのASPサービスについて
プロバイダ機能と管理コンソール、WebAPIをセットで提供してくれるASPサービスが存在します。プラン次第では制限付きで無料で利用可能なところがあります。
今回、即日利用可能なParseとpushwooshを試してみました。
実際に試してみる
ADCでAPNsへのデバイス認証のための鍵の作成
プロバイダを利用する際には、自前で用意しようとASPサービスを利用しようとアプリと一意に紐付けされたAPNs用の鍵が必須になります。
キーチェーンアクセスで証明書要求用のファイルを作成
キーチェーンアクセスのメニューから「証明書アシスタント」→「認証局に証明書を要求」
要求ファイルをディスクに保存します。
Apple Developer CenterにてAppIDを作成、Push Notificationを有効にしてAPNsのSSL証明書を取得します。
尚、Push Notificationを利用するアプリは一意である必要があるため、AppIDに*は利用できません。
XcodeのデフォルトのBundle IdentifierはProductNameによって動的に変わってしまうのでトラブルを防ぐ目的で、Bundle Identifierをリテラルで記述しておいた方がよいと思います。
キーチェーンで作成した要求ファイルをアップロードし、SSL証明書をダウンロードします。
ダウンロードしたSSL証明書はキーチェーンで内容を確認できます。
キーチェーン上の名前は変更できるようです。後で書き出ししたりする可能性があるのでわかりやすいように変更しておくことをオススメします。(参考) http://d.hatena.ne.jp/SOMTD/20120130/1327937262
アプリを実機で動作させるためのプロビジョニングファイル作成を作成しておきます。
Parseを試す
ParseにログインしDashboardの「Create New App」よりAppを登録します。
ParseにAPNsのSSL証明書を渡す必要があるので、キーチェーンアクセスから登録済みの証明書(p12)ファイルを書き出しておきます。
この際パスワードは含めてはいけないようです。
Parse DashboardのSettingより「Push notifications」を選択し「Client push enabled」をONにし、先程作成した、p12ファイルをアップロードします。
Parse SDKの組込み
Parse SDKをXcodeに組み込む必要があります。SDKはParse DashboardのDownloadsから取得できます。
ダウンロードしたSDKの中からParse.frameworkを対象のプロジェクトにドラッグ&ドロップします。更にいくつかのバイナリを追加する必要があります。プロジェクトのBuild PhasesのLink Binary With Librariesより下記のモジュールを追加します。
- AudioToolbox.framework
- Accounts.framework
- AdSupport.framework
- CFNetwork.framework
- CoreGraphics.framework
- CoreLocation.framework
- libsqlite3.dylib
- libz.1.1.3.dylib
- MobileCoreServices.framework
- QuartzCore.framework
- Security.framework
- Social.framework
- StoreKit.framework
- SystemConfiguration.framework
コーディング
AppDeletageで下記の実装を行います。
- ParseのApplicationIDとClientKeyの設定する
- iOSへRemoteNotificationTypeを登録する
- APNsから受け取ったデバイストークンをParseへ登録する
- Channelの登録
- RemoteNotificationの受け取り処理
Header
#import <Parse/Parse.h>
AppDeletage#didFinishLaunchingWithOptions:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { //Parseキー設定 [Parse setApplicationId:@"xxxxxxxxxxxxxxxxxx" clientKey:@"yyyyyyyyyyyyyyyyyyy"]; //RemoteNotificationTypeを登録 [application registerForRemoteNotificationTypes: UIRemoteNotificationTypeBadge | UIRemoteNotificationTypeAlert | UIRemoteNotificationTypeSound]; return YES; }
AppDeletage#didRegisterForRemoteNotificationsWithDeviceToken:
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)newDeviceToken { //デバイストークンをParseへ登録 [PFPush storeDeviceToken:newDeviceToken]; //Channelの登録 [PFPush subscribeToChannelInBackground:@""]; }
AppDeletage#didReceiveRemoteNotification:
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo { [PFPush handlePush:userInfo]; }
Parse ApplicationIDとClientKeyはDashboardの各アプリのOverviewから確認できます。
ParseではPush通知をChannel毎にグルーピングして送出することが可能なようです。利用方法としてはアプリのバージョン、ユーザーの地域などが考えられます。Array構造になっているのでTagのような複合的グルーピングが可能だと思います。
動作させてみる
実機にインストールして起動します。するとDashboardのData Browzerにてデバイスの登録が確認できます。
push送信してみます。DashboardのPush Notificationsの「send push」から送信のデータを作成します。
一応、送出ログと通知が届いたデバイス数などの情報が確認できるようです。一応、スケジュール送信機能も存在していますが無料プランでは制限されているようです。
REST APIを利用して通知させてみましたが問題なく通知は行えました。
> curl -X POST \ -H "X-Parse-Application-Id: xxxxxxxxxxxxxxxxxxxxxxxxx" \ -H "X-Parse-REST-API-Key: yyyyyyyyyyyyyyyyyyyyyyyy" \ -H "Content-Type: application/json" \ -d '{ "channels": [ "" ], "data": { "alert": "Push Test using REST API of Parse." } }' \ https://api.parse.com/1/push
Push通知をメインアプリではない限り月100万回を超えるケースはほぼないと思います。REST APIも無料で使えるので後から自動化する際にもさほど苦なく移行できそうな気がします。SDK、REST APIともに充実してるので細かい操作も行えそうです。
pushwooshを試す
APNs SSL証明書の取得までは同じ手順となります。Parseと同様、pushwooshにログインしてアプリケーションを登録します。
事前にAPNs SSL証明書の鍵を書き出しておきます。こちらはパスワードを含めても大丈夫です。
Certificate fileにはADCからダウンロードしたSSL証明書(.cer)、Key fileは先程書き出した鍵(.p12)をアップロードします。
次にMy Apps画面の右のペインからiOS用SDKをダウンロードし展開します。
こちらはプロジェクト毎登録する方式となります。PushNotificationManager.xcodeprojを対象のプロジェクトにドラッグ&ドロップし、Build PhasesのTarget DependencesにPushNotificationManagerを追加し、Link Binary With LiblariesにCoreLocation.framework、libPushNotificationManager.aを追加します。
Build SettingでOther Linker Flagに「-ObjC」、Header Search Pathsにヘッダーのパスを指定します。
info.plistにPushwoosh_APPIDとApplicationCodeを設定します。ApplicationCodeはpushwooshのアプリ設定画面に記述してあります。
ソースの組込み
PushNotificationManagerはDelegateによりそれぞれ実装するようです。こちらはソースの組込みに若干癖があるので使い方はSampleを参考にした方がよさそうです。
Header
#import "PushNotificationManager.h"
AppDeletage
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { PushNotificationManager * pushManager = [PushNotificationManager pushManager]; pushManager.delegate = self; return YES; } - (void) onPushAccepted:(PushNotificationManager *)manager withNotification:(NSDictionary *)pushNotification { NSString *pushExtraData = [manager getCustomPushData:pushNotification]; if(pushExtraData) { UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"Push Extra Data" message:pushExtraData delegate:self cancelButtonTitle:@"Cancel" otherButtonTitles:@"OK", nil]; [alert show]; [alert release]; } }
@protocol PushNotificationDelegate @optional //succesfully registered for push notifications - (void) onDidRegisterForRemoteNotificationsWithDeviceToken:(NSString *)token; //failed to register for push notifications - (void) onDidFailToRegisterForRemoteNotificationsWithError:(NSError *)error; //handle push notification, display alert, if this method is implemented onPushAccepted will not be called, internal message boxes will not be displayed - (void) onPushReceived:(PushNotificationManager *)pushManager withNotification:(NSDictionary *)pushNotification onStart:(BOOL)onStart; //user pressed OK on the push notification - (void) onPushAccepted:(PushNotificationManager *)pushManager withNotification:(NSDictionary *)pushNotification; //user pressed OK on the push notification - (void) onPushAccepted:(PushNotificationManager *)pushManager withNotification:(NSDictionary *)pushNotification onStart:(BOOL)onStart; @end
感想
Parseとpushwooshは即日利用可能だったので試してみました。単純に管理コンソールからPush通知するだけならどちらも簡単で1日もかからずに実装が可能です。
バージョンアップ通知を行う程度のアプリ(月に1,2回程度)であれば、Parseの方が管理コンソールも使いやすくAPIも豊富でよさそうな気がします。スケジュール機能がなくともAPIを利用して簡単なスクリプトを組めば可能です。pushをウリとしているアプリであれば、push回数無制限のpushwooshの方がいいかもしれません。(そもそもpushがウリならパフォーマンス問題もあるので自前で構築すべきだとは思います)
CORE PUSHについては試していないので機会があったら試してみようと思いますが、実際の運用で使う場合5000デバイスの制限は厳しい気がします。
パフォーマンスについては不明ですが次回のアプリの運用にはParseを試してみようと思います。
SWWDCでCoverFlowネタを発表してきました
以前のエントリーで紹介した「UITableViewでラウンドロビン的な無限スクロールCoverFlowを作る」をSWWDC(仙台iOS開発者勉強会)で発表してきました。
今回もgdgdですがライブコーディングに比重を置いてやらさせてもらいました。実際には時間の都合上、色々と端折ったので、もし最終的なソースはGithubにアップしてますので興味があれば参考ください。
https://github.com/hmori/CoverFlowTest
Core Animation for iOS
Session 1で@ushiostarfishさんによるCoreAnimationの解説とUtilityの紹介がありました。
きちんとTutorialのソースが用意されていてとてもわかりやすい内容です。デモのBounceでは非常に複雑な動きのアニメーションが実践されておりとても参考になります。
https://github.com/Ushio/SWWDC_CoreAnimation
今までアニメーションに苦手意識を持っていたのでこれを機に練習してみようと思います。Quartz Composerも使い慣れておいた方がよさそうですね。
SWWDC 2012/11/17 仙台iOS開発者勉強会 のまとめ (Togatter)
@totottiさんがまとめてくれました。
http://togetter.com/li/408923
私はSoicha(TweetMe)の開発当初、SDKもver2という時代だったのですが、当時身近に相談できる相手もいない、iOSの技術的なワクワク感があっても誰とも共有できないという寂しい経験をしました。最近では仙台でもiOS開発者が増えてきて個人的には嬉しい限りで、SWWDCの主催の@totottiさんに本当に感謝しています。これからもSWWDCには積極的にコミットしていきたいと思います。
大学で臨時講義してきました
今年も室蘭工業大学でソフトウェア工学の臨時講義を行ってきました。毎年ご紹介いただいている佐藤さんには大変感謝しています。
今回は学部2年生対象だったので難しい内容にはあまり突っ込まず、ソフトウェア工学にほとんど関係のない内容ですが興味を引きそうな動画を交えていろいろと補足していく形式で行いました。去年のアンケートでは用語が難しいとの意見を頂いていたので今年はできるだけその辺を注意しながら行いました。大半の学生はずっとみいっていたので反応は上々だったように思います。
※あくまで学生向け資料で感覚的な内容が主になっています。
前半の動画は今の学生に向けてあえて狙ってみたわけですが、やはりちょっと衝撃的だったらしく講義終了後のアンケート結果ではネガティブな感覚になった学生もいたようです。
折角なので今の学生にいろいろなサービスの利用状況を質問してみました。(90名程度)
- スマートフォン・Tablet (3割程度)
- Twitter (2割程度)
- LINE (1割程度)
- Facebook (1割程度)
- Skype (3割程度)
- Dropbox (2割程度)
- Evernote (1割以下)
Skypeが意外と多くLINEが予想外に少ないです。Evernoteの利用が少ないのは理解できますが意外にDropbox利用者が多いです。スマホの利用は去年より若干増えた感じはありますが、やはり理系学生はSNSの利用率は低い感じがします。主観ですが、理系はツールは必要に迫られて使う感じで、特にコミュニケーション・自己表現といったところの意識が薄いのかもしれません。
毎度ながらスマホを持っていない人には強く持つことを進めています。IT技術を勉強するためだけの小さな理由ではなく、インターネットの進化により世の中の文化が変わっており、これからの生きていく上でいろいろと順応するために様々な情報に触れて慣れておくことが必要だからです。情緒力・論理的思考力の根底が語彙力にあると言われるのと同じように、情報によく触れる人とそうでない人の間に思考の幅に累乗的な開きが出ます。10年後には情報格差(デジタルデバイド)が国内でも飛躍的に広がり世の中の価値観も変化している気がします。