iOSでUIWebView内のリンクをタップしたらNavigationで画面遷移させる実験
しばらくiOSから離れていてSwiftはほとんど触ったことないのでとりあえずObjective-Cでやりました。このあとSwiftで実装し直そうと思ってるけれど、一旦エントリにします。
いつだったかDHHが既存のWebサイトを活かしてアプリ作るよーみたいなことを言ってたのを覚えていたので、今回は「いや、実際どうやってやるんだろうな」というのを考えてみたわけです。
Hybrid sweet spot: Native navigation, web content, https://t.co/LWmqbmg3iX — How we build mobile apps for Basecamp with tiny teams.
— DHH (@dhh) 2014, 5月 8
何をやりたいか
こういうページ遷移がある普通のWebサイトがあるとして、
このページをiOSのUIWebViewで表示するんだけど、リンクをタップしたときに新しいViewControllerを作ってNavigationで遷移するということをやってみます。普通にやるとリンクをタップしたら同一WebView内画面遷移するところを、あたかもiOSのネイティブのように動かしてみようということね。
結論からいうとそれは可能です。↓のアニGIFを見てもらえばリンクを押したときにナビゲーションで次のビューがスタックしてくるのがわかると思います。
こうすることでWebにコンテンツを自由にデプロイしつつ、ユーザー体験はネイティブに近づけられるんじゃないかと。
こんなことはすでに昔からみんながやってたりしたかもしれないけど、最近は端末も速くなっているし、個人的にはWebView+ナビゲーションってあまり見たことがない気がするのでその部分の実験ということで見てもらえると良いです。
Webページの準備
まずはHTML側のソースです。ここで見るべきは、<a>
タグについてるcallback-to-ios
クラスぐらい。
<!DOCTYPE html> <html> <body> <p>1ページ目</p> <a href="/secondpage.html" class="callback-to-ios">2ページ目ヘ</a> </body> </html>
続いてJavascriptです。こっちが肝ね。
$ -> reportBackToIOS = (href)-> iframe = $('<iframe />').attr('src', "callback://" + href) $('body').append(iframe) iframe.remove() iframe = null $('.callback-to-ios').click -> reportBackToIOS($(@).attr("href")) false
まずリンクのクリック時にreportBackToIOS
を呼び出した上で、falseを返してリンクを辿らないようにしてあります。
reportBackToIOS
は名前の通りiOSにリンクがタップされたことを知らせる役割を担っています。iOSのUIWebViewではWebView内でURLの読み込みが発生する直前にコールバックを通るんですが、ここではそれを利用しています。
具体的には、iOSに渡したい情報をURL(の一部)として持ったiframe
を作って一旦HTMLのbodyに追加し、すぐに削除していますね。iframe
がbodyに追加されたとき、src
アトリビュートで持っているURL(ここでは、'callback://' + 遷移先のURL)をiframe内で開こうとするため、WebViewのコールバックが呼ばれることになるわけです。
iOS側の対応
ViewController
に適当にUIWebViewを置いてあり、ViewController
→ViewController
の遷移をするSegueがnext
という名前で定義されているとします(実際にはViewControllerからViewControllerへのSegueは定義できないので、隠しボタンからViewControllerへのSegueです。)。
#define HOST @"http://localhost:4000" - (void)viewDidLoad { [super viewDidLoad]; self.webView.delegate = self; NSString* urlstring = NULL; NSString* path = self.segueOptions.stringValue; if(path) { urlstring = [NSString stringWithFormat:@"%@%@", HOST, path]; } else { urlstring = [NSString stringWithFormat:@"%@%@", HOST, @"/"]; } NSURL* url = [NSURL URLWithString:urlstring]; NSURLRequest* urlRequest = [NSURLRequest requestWithURL:url]; [self.webView loadRequest:urlRequest]; }
肝心のコールバックはこんな感じで実装しておきます。
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType { if ([[[request URL] scheme] isEqualToString:@"callback"]) { NSString* href = [[[request URL] absoluteString] substringFromIndex:11]; [self performSegueWithIdentifier:@"next" options:href]; return NO; } return YES; }
shouldStartLoadWithRequest
は新しいリクエストが来た時に通るコールバックで、何もしないときはYES
を返すことで読み込みをスタートさせることができるわけですが、今回はcallback://
で始まるURLの場合だけはnext
という名前のSegueを実行してNO
を返すことにします。NOを返せばURLの読み込みは行われないので、callback://****
みたいなURLを処理しちゃってエラー、ということも起こりません。
これで最初に紹介したGIFアニメのようなページ遷移ができるようになりました。便利なような、使いどころ難しいようなそんな感じですが、ネイティブっぽく動かすという目的はなんとなく果たせていますね。
参考リンク
reportBackToIOS
のところはStackOverflowのこの質問を参考にしました。
ios - How can I reliably detect a link click in UIWebView? - Stack Overflow
クラウドでできるHTML5ハイブリッドアプリ開発 Cordova/Onsen UIで作るiOS/Android両対応アプリ (Monaca公式ガイドブック)
- 作者: 永井勝則,アシアル株式会社
- 出版社/メーカー: 翔泳社
- 発売日: 2015/02/18
- メディア: 大型本
- この商品を含むブログ (4件) を見る
markdown_section_numbering gemをアップデート
昔こんなのを作ったわけだけど、本当に少しだけ足りないなと思うところがあったのでアップデートしました。
上の記事で、「Automatorと連携して選択中のテキストを変換する」っていう使い方について書いたのだけれど、それをやるには標準入力を受け取ってgemのクラスに渡して標準出力するっていうスクリプトをわざわざgem外で用意する必要があったわけです。
そのときは気が回っていなかったんだけど、そんな機能はgemに実行ファイルとして含めればいいということにようやく気がついたので今回のアプデに至りました。
markdown_section_numbering | RubyGems.org | your community gem host
バージョン1.1をインストールするとmarkdown-section-numbering
というコマンドが使えるようになります。このコマンドにMarkdown形式のテキストを与えると番号付きのマークダウンが標準出力に返ってきます。
中身は単純でこんな感じ。
#!/usr/bin/env ruby begin require 'markdown_section_numbering' rescue LoadError require 'rubygems' require 'markdown_section_numbering' end $stdout.write MarkdownSectionNumbering.convert($stdin.read)
- 作者: Peter J. Jones,arton,長尾高弘
- 出版社/メーカー: 翔泳社
- 発売日: 2015/01/09
- メディア: 大型本
- この商品を含むブログ (11件) を見る
Rubyで濁点/半濁点が分離した文字を1文字に統一するには
Macのファイル名周りで発生するのでハマったことがある人もいると思うんだけど、ひらがなやカタカナの濁音や半濁音の文字を表現するのに2文字分使われていることがあったりする。
例えばFinderでぱぴぷぺぽ.txt
というファイルを作ってEmacsで開いてみるとこんなふうになる。
Rubyでもこの手のファイル名から文字列を作ったりすると同じようなことが起こる。例えばこんな感じで。
同じ文字列に見えて全く違うってことが起こる。こういう文字列がデータベースに入っていると検索にかからないことがあるし他にも色々困ったことになりそうな感じがする。
なお、この手の話を真面目に語るには僕の知識は足りないので今回はどうやって回避するかを書くだけに留めるけど、軽く触れておくとUnicodeの正規化に関係する現象らしいということがわかった。
対策
このタイプの文字列が入力として入ってきそうな箇所で正規化の形式を変換しておくことで対処できた。変換にはActiveSupportのメソッドを使用した。
normalize (ActiveSupport::Multibyte::Unicode) - APIdock
# 例えばファイル名から文字列をとってくる name = File.basename(textfile, '.txt') # "は゜ひ゜ふ゜へ゜ほ゜" name = ActiveSupport::Multibyte::Unicode.normalize(filename, :c) # "ぱぴぷぺぽ"
Azure Storage QueueでBase64関係の例外
バッチ処理のためのメッセージキューとしてMicrosoftのAzure Storage Queueを使っている。C#での使い勝手は言わずもがなだし、Rubyでも最低限のSDKが提供されているのでAWSと比べて使い勝手が悪いということはない。
ただ、Rubyでメッセージを追加してC#でメッセージを取り出すということをやっていたらエンコード関係ではまったのでその部分について今日はメモしておきたい。
例外が発生するケース
Rubyでメッセージ追加
基本的には公式のドキュメントを参考にした。
キュー サービスを使用する方法 (Ruby) | Microsoft Azure
メッセージを追加はRailsアプリケーションから行いたかったので、上のドキュメントを参考にしてこんな風にかいた。
# config/initializer/azure.rb Azure.configure do |config| config.storage_account_name = ENV['AZURE_STORAGE_ACCOUNT'] config.storage_access_key = ENV['AZURE_STORAGE_ACCESS_KEY'] end AZURE_QUEUE_SERVICE = Azure::QueueService.new AZURE_QUEUE = ENV['AZURE_QUEUE'] AZURE_QUEUE_SERVICE.create_queue(AZURE_QUEUE)
# in controller AZURE_QUEUE_SERVICE.create_message(AZURE_QUEUE, @model.id.to_s)
C#でメッセージ取り出し
同じく公式ドキュメントを参考にして、メッセージを取り出す部分を書いた。
private void Func() { var sc = new StorageCredentials( Constants.AZURE_STOREGE_ACCOUNT, Constants.AZURE_STOREGE_ACCSESS_KEY ); var storageAccount = new CloudStorageAccount( sc, true ); var queueClient = storageAccount.CreateCloudQueueClient(); var queue = queueClient.GetQueueReference( Constants.AZURE_QUEUE ); queue.CreateIfNotExists(); var timeSpan = new System.TimeSpan(1, 0, 0); var message = queue.GetMessage( timeSpan ); messsage.AsString; }
例外発生
実装はめちゃくちゃ簡単だしこれで動くだろうと思ったのだが、message.AsString
の部分でBase-64 文字配列または文字列の長さが無効です。
という例外が発生してしまった。しかもAzure Storage Explorerなどのツールでも同じ例外が起こった。
SDKのソースを見てみたところ、メッセージにはエンコードした文字列かそうでないものかのタイプを持っているらしいが、どうやら今回のケースでは部エンコードされていない文字列であるにもかかわらず、タイプがエンコード済となっていて、想定外のコードを通ることになってしまっていたらしい。
azure-storage-net/CloudQueueMessage.Common.cs at master · Azure/azure-storage-net
/// <summary> /// Gets the content of the message, as a string. /// </summary> /// <value>A string containing the message content.</value> public string AsString { get { if (this.MessageType == QueueMessageType.RawString) { return this.RawString; } else { byte[] messageData = Convert.FromBase64String(this.RawString); return utf8Encoder.GetString(messageData, 0, messageData.Length); } } }
回避策
これもソースをみたらわかったんだけど、RubyのSDKでエンキューするときにエンコードオプションを指定できるみたいだった。
def create_message(queue_name, message_text, options={}) query = { } unless options.empty? query["visibilitytimeout"] = options[:visibility_timeout] if options[:visibility_timeout] query["messagettl"] = options[:message_ttl] if options[:message_ttl] query["timeout"] = options[:timeout].to_s if options[:timeout] end uri = messages_uri(queue_name, query) body = Serialization.message_to_xml(message_text, options[:encode]) call(:post, uri, body, {}) nil end
options[:encode]
の部分!
そういうわけで、最初の実装にオプションを追加してこうしてあげればよかった。
# in controller AZURE_QUEUE_SERVICE.create_message(AZURE_QUEUE, @model.id.to_s, encode: true)
例外もでなくなりました。めでたし!
ActiveRecordの日付カラムでレコードを絞り込める by_star gem
久々に便利なの見つけた。有名だったりするのかな?
by_starはモデルの絞り込みに使えるgemで、ActiveRecordとMongoidで使える。
ある期間内のレコードだけを表示したり集計を取ったりというときに使える。自分でも大したコードにはならないんだけど汎用的なものなのでこのgemを使うのが良いでしょう。
githubのREADMEを見れば一目瞭然なのだけど、一応紹介しておく。
Post.by_year(2013) # all posts in 2013 Post.before(Date.today) # all posts for before today Post.yesterday # all posts in 2013 Post.between_times(Time.zone.now - 3.hours, # all posts in last 3 hours Time.zone.now) @post.next # next post after a given post
カラムを指定しない場合はcreated_at
が使われる。自分で定義したカラムを使いたいなら次のようにすればOK。
Post.by_year(2013, field: :hogehoge_at) # hogehoge_at という Date/DateTimeなカラムを使用
こういう絞り込みでよくあるのは○年○月のデータ一覧みたいなのだと思うのでそのやりだけ簡単に書いておく。
Post.by_year # 今年のpost Post.by_month # 今月のpost Post.by_month(4) # 今年の4月 Post.by_month(4, year: 2012) # 2014年の4月
自前カラムを使うならscopeにしとくと便利だと思う。
class Post < ActiveRecord::Base scope :by_year_month, ->(y, m) { by_month(m, year: y, field: :hogehoge_at) } end
実装を見てみると最終的にはbetween_times_query
ていうメソッドに行き着くっぽかった。
def between_times_query(start, finish, options={}) start_field = by_star_start_field(options) end_field = by_star_end_field(options) scope = by_star_scope(options) scope = if options[:strict] || start_field == end_field scope.where("#{start_field} >= ? AND #{end_field} <= ?", start, finish) else scope.where("#{end_field} > ? AND #{start_field} < ?", start, finish) end scope = scope.order(options[:order]) if options[:order] scope end
単機能をいい感じに切り出せてて良さげです。この発想は見習うとこありそう。
Grapeで独自のレスポンスヘッダーを追加する
先日こんなエントリーを書いた。
Grapeを使ったAPIで独自のエラーコードも一緒に返す工夫 - PILOG
その後色々調べているうちに、エラーコードはレスポンスヘッダーに入れたらいいよ~という話を見かけたので、その方針を採るかどうかは別として実現方法を調べてみた。
YappoLogs: 2014年に向けた JSON API の実装の方向性と X-JSON-Status 改め X-API-Status header のご提案
調べたところによるとgem、GrapeにはHTTPヘッダーを追加するインターフェースが用意されているらしい。エラー時にヘッダーを追加するには
error! 'Unauthorized', 401, 'X-Error-Detail' => 'Invalid token.'
と、こんな風に書けばいいそうだ。
以前の続きから
先ほどリンクを貼った自分のエントリーではエラーフォーマッターをカスタマイズした上でヘルパーを使ってJSONのレスポンスを返す感じにしていた。
Grapeを使ったAPIで独自のエラーコードも一緒に返す工夫 - PILOG
HTTPヘッダーに項目を追加するためにはヘルパーメソッドをちょちょっと変更してあげるだけで済みそうだ。
before
def my_error!(error) error!({message: error.message, code: error.code}, error.status) end
after
def my_error!(error) error!({message: error.message, code: error.code}, error.status, "X-API-Status" => error.code.to_s, "X-API-Error-Message" => error.message) end
とまあ、大体こんな感じ。1つ目の引数と内容的にはダブっているし、ヘッダーのフィールド名?っていうのかな?それも適当なのでもうちょっとブラッシュアップする必要がりそうだが、一応レスポンスヘッダーにエラー詳細情報を載せることはできそうだ。
このAPIを呼び出すクライアントを実装するのであればレスポンスヘッダーを解析して、HTTPのStatusとこのX-API-Status
を見て処理を分岐すればいいんだと思う。確かにJSONのパースは不要になるからこちらのほうが(レスポンスBodyにJSONとしてエラー詳細を含めるよりも)いい気がしなくもない。
クライアントの実装も自分でやることになるので、サーバー側の実装はどちらの手段も提供するようにしておいてあとから決めることにしよう。
Grapeを使ったAPIで独自のエラーコードも一緒に返す工夫
RailsでGrapeをっていうgemを使うとちょちょいっとAPIが作れるという話は以前にも書いた。
このときはお試しだったんだけど、今回ちょっと本格的に書くかもという感じになってちゃんとエラー処理とかもしないといけなくなった。それにあたり試行錯誤したことをちょっとメモっておこうと思う。
普通にエラーを返す
以下フォーマットはJSONだとする。
エラーはerror!
メソッドによって発生させることができる。error!
は例外を使っているようなので呼んだ時点で処理が中断するため、エラー発生後の処理のことは考えなくていい。
class API < Grape::API resource :users do get '/' do error!("Unauthorized! Invalid token.", 401) end end end
このAPIからはステータスコード401で次のようなJSONが戻ってくる。
{ "error" : "Unauthorized! Invalid token." }
独自のエラーコードも戻したくなる
具体的にどのようなエラーが起こったか、という詳細はエラーメッセージやHTTPのステータスコードだけで判別するのは厳しい。アプリケーション独自のエラーコードを使ってもっと詳細な情報まで返してあげたほうが呼び出し元のクライアントためになると思う。
そのためにGrapeのErrorFormatterというのを使える。文字通りエラー出力のフォーマットをカスタマイズするための物だと思う。
こんなFormatterを定義して、error_formatter
に指定してみた。
module ErrorFormatter def self.call message, backtrace, options, env if message.is_a?(Hash) { error: message[:message], code: message[:code] }.to_json else { error: message }.to_json end end end class API < Grape::API error_formatter :json, ErrorFormatter resource :users do ... end end
1つめの引数message
にはerror!
メソッドの第1引数がそのまま渡るので、ここにHashを渡してしまおうというわけ。
使い方はこうなる。
error!({message: "Unauthorized!", code: 123}, 401)
レスポンスはこんな感じになる。
{ "error" : "Unauthorized!", "code" : 123 }
あとはドキュメントとかを整備しておけば123番のエラーはこれだ!みたいなのがより詳細に判別できるようになると思う。
ヘルパーにする
毎度Hashを組み立てるのが面倒だからGrapeのヘルパー機構を使ってもう少し楽に呼び出せる方が良さそう。
helpers do def my_error!(message, error_code, status) error!({message: message, code: error_code}, status) end end my_error!("Unauthorized!", 123, 401) # こう呼べる
エラー定義をまとめる
APIのいろいろな箇所でmy_error!(うんぬん)
があるのは生じい鬱陶しいんじゃないかと思う。あそこのエラーメッセージちょっと変えたいなと思ってもいろいろなところから探さないといけないし。
ということで、エラー定義を一箇所にまとめる方法を模索している。今のところrails_config
gem を使って、環境によらずアプリケーション全体でその設定を使いまわすような感じで作ってみている。
RailsConfigの導入は公式を見れば十分だと思う。
導入できたら全体の設定ファイルであるconfig/settings.yml
にいろいろ書いていく。
errors: unauthorized_token: message: Unauthorized. Invalid token. code: 124 status: 401 unauthorized_user: message: Unauthorized. Invalid user. code: 125 status: 401
RailsConfigにより、上のように書いた内容はアプリケーション内でSettings.errors.unauthorized_token.message
などとして呼び出せるため、エラー箇所がすっきりするんじゃないかと思う。
最終的にはmy_error!
を書き換えていい感じにしてみた。
def my_error!(error) error!({message: error.message, code: error.code}, error.status) end # 呼び出し my_error!(Settings.errors.unauthorized_token)
定義も一覧しやすくなったし、呼び出しもすっきりした。まあまあいいところに落ち着いたと思う。
もっといい方法、一般的な方法があったら知りたいです。