- 投稿日:2020-02-19T16:02:22+09:00
CordovaのUIWebView→WKWebView移行についてまとめ
UIWebViewを使用している場合、新規リリースは2020/4、アップデートは2020/12からApp Storeがアプリを受け付けなくなります。
https://developer.apple.com/news/?id=12232019b代替手段としてWKWebViewがあります。
UIWebViewからWKWebViewに移行するに当たり、手順やハマった点をまとめます。(主にCordovaにおいてのノウハウですが、Cordovaに限らない内容も記載します)同時期にXcode11のビルドも必須になりますが、それについては以下投稿を参照ください。
https://qiita.com/kishisuke/items/b1c0096dc832972da9be他に何かあったら随時追記します!
UIWebViewを利用しているか確認する方法
UIWebViewを実際に画面に表示していなくとも、リンクしている場合はリジェクトされる可能性があります。
UIWebViewがリンクされているかは、以下のコマンドで確認可能です。nm <バイナリファイルのパス> | grep UIWebView
※バイナリファイルのパスは以下で確認。
- ipaファイルの場合
- ipaファイルを解凍する(拡張子をzipに変更して、zip形式として解凍)
- {ファイル名}/Payload/{ファイル名}.app/{ファイル名}
- appファイルの場合
- {ファイル名}.app/{ファイル名}
以下はUIWebViewがリンクされているリリースビルドのipaファイルを確認した際の結果例です。
何も表示されなかったらOKです。U _OBJC_CLASS_$_UIWebView U _OBJC_METACLASS_$_UIWebView U _OBJC_CLASS_$_UIWebView U _OBJC_METACLASS_$_UIWebViewまた、UIWebViewがリンクされているアプリをAppStoreにアップロードすると、以下の警告メールが届きます。(2020/02/19時点)
ITMS-90809:非推奨のAPIの使用-AppleはUIWebView APIを使用するアプリの提出を受け付けなくなります。詳細については、https://developer.apple.com/ documentation / uikit / uiwebviewを参照してください。
基本的な対応方法
WebViewのEngineをWKWebViewに変更
Cordovaは標準のEngineとして、UIWebViewを利用しています。
WKWebViewを利用するには、cordova-plugin-wkwebview-engineをインストールします。ただ、EngineをWKWebViewに変えただけでは、UIWebViewはリンクされたままです。
UIWebViewをリンクしなくするには、Cordova iOSを5.1.1にアップデートして、config.xmlのWKWebViewOnlyをtrueに設定してください。config.xml<preference name="WKWebViewOnly" value="true"/>このフラグをONにすることで、プリプロセッサでUIWebViewを参照しているコードが除去されます。なお、次期メジャーバージョンで完全にUIWebViewのコードを除去して、プラグインなしでWKWebViewを標準のEngineとして採用するとのことです。
https://github.com/apache/cordova-discuss/pull/110#issuecomment-573036332Note: 2020/02/19時点ではMonacaはCordova iOS 5.1.1に対応していません。対応を待ってからアップデートしましょう。
サードパーティプラグインの対応
サードパーティのプラグインでもUIWebViewをリンクしている可能性があります。
その場合、プラグインをアップデートまたは削除(必要であれば代替実装に移行)します。以下は対応例です。
InAppBrowser
InAppBrowserはWKWebViewに対応しており、UIWebViewの非リンク化も3.2.0から対応しています。
Firebase SDK
6.8.0と6.8.1からUIWebViewの非リンク化に対応しています。
6.8.1未満の場合は、アップデートしましょう。AFNetworking
2020/02/19時点(3.2.1)ではUIWebViewをリンクしています。プルリクが作成されており、もう少し経つとマージされるかもしれません。
https://github.com/AFNetworking/AFNetworking/pull/4439ハマった点
LocalStorageなどのデータ移行
UIWebViewの場合、LocalStorageやWebSQL、IndexedDBなどのブラウザに永続化するデータは、アプリのデータ領域にファイルとして保存されています。
WKWebViewも同様ですが、ファイルの保存先がUIWebViewと異なるため、単純にUIWebViewからWKWebViewにアップデートすると、WebView上のJavaScriptから見るとデータがロストした様に見えます。
そのため、元々UIWebViewを埋め込んでいたアプリをWKWebViewにアップデートする場合は、LocalStorageなどのデータ移行が必要になります。(LocalStorageなどをキャッシュ情報としてしか利用していない場合はその限りではありません)LocalStorageに限れば移行するCordovaプラグインがあります。実際にiOS 13.3で確認したところ、データが移行できていました。(実際に使用する際は、各OSバージョンごとに検証するなどしてください)
https://github.com/MaKleSoft/cordova-plugin-migrate-localstorageCordovaでXHRがエラーになる
CordovaのWebアプリ(index.html)はfile://スキームでアクセスします。そのため、例えばfile://スキームからインターネット上で公開されているHTTPSのAPIにアクセスしようとすると、オリジンが異なるためエラーになります。
UIWebViewではこの制約は無視されていましたが、WKWebViewではブラウザと同様にクロスドメイン通信を行う場合はCORSに対応する必要があります。対応方法は以下記事などを参照してください。
https://qiita.com/tomoyukilabs/items/81698edd5812ff6acb34WKWebViewのUserAgentを変更する方法
以下の通り、3つの方法があります。
- UserDefaultsで設定
- WKWebView#customUserAgent で設定
- WKWebViewConfiguration#applicationNameForUserAgentで設定
※https://blog.anzfactory.xyz/articles/20190902/swift-wkwebview-custom-useragent/ を参考にさせていただきました。
この時、優先順は2>1>3です。
1と2は設定したUAがそのまま送信されるが、3は元々のUAの末尾に付く。
1と2で3と同じ様にしたい場合は、自身で元々のUAを取得する必要がある(UAはUIWebViewと同様にJavaScriptを実行して取得する必要があるが、WKWebViewのJS実行処理は非同期のため扱いづらい)
と言う点が異なります。3の方法が良さそうですが、他のライブラリが1でUAを設定している場合、優先順の関係で適用されない問題があります。実はCordovaが独自に1の方法でUAを設定しているため、3の方法で設定したUAが無視されてしまいました?
そこで、以下の方法で対応しました。
- 1の方法でUAが設定されている場合は、1の方法でUAを書き換え。
- それ以外の場合は3の方法でUAを設定する。
以下サンプルです。
NSString *originalUserAgent = [[NSUserDefaults standardUserDefaults] stringForKey:@"UserAgent"]; if (originalUserAgent != nil && originalUserAgent.length > 0) { NSString *newUserAgent = [self editUserAgent: originalUserAgent]; // 何らかの編集処理 [[NSUserDefaults standardUserDefaults] registerDefaults:@{ @"UserAgent": newUserAgent }]; } WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init]; configuration.applicationNameForUserAgent = @"AppName"; self = [super initWithFrame:CGRectZero configuration:configuration];ちなみに、CordovaはUAを以下の様にして無理やり同期的に取得しています。(JSが実行完了するまで、メインスレッドをブロック)
1と2の方法で対応する場合で、非同期にUAを取得するのが大変な場合は、参考になるかもしれません。
https://github.com/apache/cordova-ios/blob/e8a041ef949309b1f23dfc7a03de0f019c831359/CordovaLib/Classes/Public/CDVUserAgentUtil.m#L44-L65Cordova以外でハマった点
POSTの問題
Content-Typeの設定
iOS13でWKWebViewでボディをPOSTする場合は、Content-Typeヘッダーを明示的に設定する必要があります。(iOS12までは
application/x-www-form-urlencoded
の場合、Content-Typeヘッダーの設定は不要でした)NSString *url = @""; NSMutableString *body = @"name=value"; NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]]; [req setHTTPMethod:@"POST"]; [req setHTTPBody:[body dataUsingEncoding: NSUTF8StringEncoding]]; [req addValue:@"application/x-www-form-urlencoded" forHTTPHeaderField:@"Content-Type"]; // iOS 13から必須 [self loadRequest:req];ボディが空になるバグ
iOS9〜10のWKWebViewでPOSTするとボディが空になるバグがあります。
https://www.lanches.co.jp/blog/8953以下投稿などの対応方法がありますが、Cookieでセッションを生成しているなどの場合に対応できない可能性があります。
https://qiita.com/shunkitan/items/a23b0b109f434a28ab8b#1-wkwebview%E3%81%A7%E3%81%AFpost%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%81%AEhttp-body%E3%81%8C%E7%84%A1%E8%A6%96%E3%81%95%E3%82%8C%E3%82%8BiOS11でバグは修正されているため、対応するOSをiOS11以降とするのも手です。
2020/02/19時点で94%のiPhoneユーザーはiOS12以降を利用しており、現実的な手段だと思います。WKWebViewに移行すると良いこともある
対応方法だけ書くと面倒なだけに感じますが、WKWebViewにすることで大きなメリットもあります。
例えば、引っ張って更新するUIを実装するとflickingするバグなどはWKWebViewにすると直ります。UIWebViewの謎挙動に悩まされてきた人多いんじゃないでしょうか(私です)
その他にも全体的にパフォーマンスが向上したり(特にスクロール)、機能的にもSign in with Appleが動くなど恩恵があるので、是非この機会に頑張って対応しましょう!
- 投稿日:2020-02-19T15:08:21+09:00
【ARKit】.scnファイル用のアセットフォルダを作成
はじめに
以下の記事を参考にARKit上に3Dモデルを表示するテストをしていたのですが、表題の件で詰まったので備忘録として。
実行環境
- Xcode 11.2.1
- Projectは、Single View App として作成
.scnファイル用のアセットフォルダって何?
.scnファイルを入れるためのアセットフォルダ作成します。
とあり、これはどうやって作るのかがわからなくていろいろ調べたところ「SceneKit Catalog」のことで、Appleの公式ドキュメントに書いてありました。
For best results, place scene files that ship in your app bundle in a folder with the .scnassets extension, and place image files referenced as textures from those scenes in an Asset Catalog.
(訳)最良の結果を得るには、アプリバンドルに含まれるシーンファイルを.scnassets拡張子を持つフォルダーに配置し、それらのシーンからテクスチャとして参照される画像ファイルをアセットカタログに配置します。
.scnassetsの作り方
ちなみに
新規Projectを作るときに、Gameを選択して Game Techinology:SceneKitを選択した場合は、最初から art.scnassets が作成されて飛行機のモデルのシーン(ship.scn)とそのテクスチャ(texture.png)が入っています。
まとめ
ARKit、ほぼ初めてなので、いろいろわからないこと多いです。ちょっとずつ試してみる予定です。
以下の記事も参考になりました。ありがとうございます。
- 投稿日:2020-02-19T12:01:14+09:00
【bitrise】Undefined symbols for architecture x86_64が出る時に疑う事
自分がハマったのでtipsの共有です。
問題
bitriseで何回ビルドを行っても「Undefined symbols for architecture x86_64」というエラーが出るので困り果てていたのですが、すごく些細な理由で問題が起きていました。
原因
XcodeのSchemeで、
Gather coverage for
にチェックマークしていないのに、bitrseのXcode tests for iOS
のステップで、Generate code coverage files?
のオプションをyes
にしていることが原因です。bitriseのテストステップは内部でxcodebuildを使ってテストを実行しているのですが、これをyesにすると
GCC_GENERATE_TEST_COVERAGE_FILES
というオプションがYES
でコマンドに渡されるようになります。Gather coverage forをyesにすると、xcodeproj内のxcschemeの
codeCoverageEnabled
というフィールドがYESになるのですが、この項目がYESではないのにGCC_GENERATE_TEST_COVERAGE_FILES=YES
のオプションを渡しているためエラーが発生したようです。xcodebuild.shset -o pipefail && env "NSUnbufferedIO=YES" xcodebuild "-workspace" "Some.xcworkspace" "-scheme" "Some" "build" "COMPILER_INDEX_STORE_ENABLE=NO" "test" "-destination" "id=DDD6FC97-6DB4-40D9-89AB-0B9C5541B18B" "-resultBundlePath" "/var/folders/6q/wgy6jtp12w5gzgm9lzcglpqw0000gn/T/XCUITestOutput157008135/Test.xcresult" "GCC_INSTRUMENT_PROGRAM_FLOW_ARCS=YES" "GCC_GENERATE_TEST_COVERAGE_FILES=YES" | xcpretty "--color" "--report" "html" "--output" "/Users/vagrant/deploy/xcode-test-results-CanDO.html解決
bitrseの
Xcode tests for iOS
のステップで、Generate code coverage files?
のオプションをno
に設定した発生しなくなりました。そもそも収集していないのに生成しようとすると、タイトルの
Undefined symbols for architecture x86_64
というエラーが投げられるようです。Bitriseでハマった時のデバッグ
デバッグに効果的だった方法を書き残しておきたいと思います。
- ローカルとCIの環境がバラバラにならないように、パッケージ管理の仕組み(Bundlerなど)を使う
- CIのログを読んで、ローカルで同じコマンドを実行してみる
特に2番目は大事で、CIはVM立ち上げるのが遅いのでちょっと変えて試してがやりにくくて、デバッグはし辛いです。なので、手元で同じコマンドを打って、どのようなコマンド,オプションの渡し方をすればこの問題は解決するのか。というところから逆算でCIのステップを直していくのが効果的でした。
- 投稿日:2020-02-19T11:46:33+09:00
今更聞けない「バージョン判定」
はじまり
さまざまなOSのバージョンアップで軒並みエラーとか苦しめられる時期が来るプログラミングの見直し時期はいつも、新しいコードのチェックとバーションアップした時の検証
見積書には、なかなか書けないかもしれないですが、それこそ見積もらなくてはいけないこのOSバージョンアップへの対応 いつまで経っても古いままだと、警告や使えなくなっていく機能などが増えてくる
iOSの悩みどころのこの時期にチェックしておきたい。
バージョン判定のお話です。いつも忘れてしまうので、備忘録として、iOSのバージョン判定するには、以下の方法で行うようにしていきたいって言う提案もありますが、皆さんのご意見も含めここにまとめていこうと思います。
Objective-C 今のバージョンが知りたい場合 の例
Objective-C// バージョン判定 [UIDevice currentDevice].systemVersion.floatValue >= 7;OS version 7.0 以上と言うのが分かります。
Objective-C マクロを使って一行で書く方法 の例
Objective-C#define SYSTEM_VERSION_EQUAL_TO(v) ([[[UIDevice currentDevice] systemVersion] compare:v options:NSNumericSearch] == NSOrderedSame)OS version の判定の時に使えそうです。
参考:マクロを使って一行で書く方法Objective-Cfloat iOSVersion = [[[UIDevice currentDevice] systemVersion] floatValue]; if (iOSVersion > 9.9f) { // iOS10以降の場合 } else { // iOS9以前の場合 }上記のように、iOSのバージョン判定で、直接値が書く方法でもこちらは、有効です。
iOS 8.0 で NSProcessInfo に追加されたメソッドを使用すると簡単にバージョンの判定が行える。
Objective-CNSOperatingSystemVersion version = {8, 3, 0}; BOOL isOSVersion8_3Later = [[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:version]; if (isOSVersion8_3Later) { // iOS 8.3 以降 } else { // iOS 8.3 未満 }上記のように、iOS のバージョンを簡単にマイナーバージョン等を加味して判定する方法もあるようですね。
参考:iOS のバージョンを簡単にマイナーバージョン等を加味して判定する方法Objective-C- (BOOL)isOS10 { BOOL isOS10 = NO; if ( floor(NSFoundationVersionNumber) > NSFoundationVersionNumber_iOS_9_x_Max ) { // iOS10以降の場合 isOS10 = YES; } else { // iOS9以前の場合 } return isOS10; }上記のように、iOS のバージョンを判定する場合においては、こちらのisOSXX を用意しておくのも後々開発の時に便利かもですね。
理由としては、OSを全体にバージョンアップした場合は要らなくなるコードが増え、全体的なコストやリスクを回避できるからですね。
Objective-C- (BOOL)isOS10Handler:(void (^)(void))iOSHandler otherHandler:(void (^)(void))otherHandler { BOOL isOS10 = NO; if ( floor(NSFoundationVersionNumber) > NSFoundationVersionNumber_iOS_9_x_Max ) { // iOS10以降の場合 if (iOSHandler) { iOSHandler(); } isOS10 = YES; } else { // iOS9以前の場合 if (otherHandler) { otherHandler(); } } return isOS10; }上記のように、好みにもよりますが上記のハンドリングを用いておけば、判定と変更もメソッド内で個別に対応が出来たりします。
レイアウト調整の場合においてもですが、切り替える際にコードで書かなくてはならない時はこちらが便利です。
ブロック構文に関しては別の時に学んでいきます。iOSのバージョンによって異なるデザイン(iOS7以前)やiOS10から変わったウィジェットの背景色などを調整したりする際にこの判定処理が使えます。
以下がその例です。Objective-C 切り替える場合 の 例
Objective-Cif ( floor(NSFoundationVersionNumber) > NSFoundationVersionNumber_iOS_9_x_Max ) { // iOS10以降の場合 } else { // iOS9以前の場合 }iOS10以降の場合
Objective-Cif (@available(iOS 13.0, *)) { // iOS13以降の場合 } else { // iOS13未満の場合 }iOS13以降とiOS12以前の場合で切り替えることが可能です。
特に、Popoverの時などのレイアウト変更の時はよく使いました。
こちらの苦労話は、またどこかで書きたいと思います。Swift 切り替える場合 の 例
Swiftif floor(NSFoundationVersionNumber) > NSFoundationVersionNumber_iOS_9_x_Max { // iOS10以降の場合 } else { // iOS9以前の場合 }iOS10以降の場合
Swiftif #available(iOS 10.0, *) { // iOS10以降の場合 } else { // iOS9以前の場合 }関連記事
- マクロの書き方
- Popover
- ブロック構文
- バーションアップ
- Objective-C 一覧
- Swfit 一覧
制作チーム:サンストライプ
http://sunstripe-main.jp/
(月1WEBコンテンツをリリースして便利な世の中を作っていくぞ!!ボランティアプログラマー/デザイナー/イラストレーター/その他クリエイター声優募集中!!)地域情報 THEメディア
THE メディア 地域活性化をテーマに様々なリリース情報も含め、記事をお届けしてます!!
https://the.themedia.jp/ゼロからはじめる演劇ワークショップ
多様化の時代に向けて他者理解を鍛える
プログラミングワークショップ・ウェブ塾の開講!!!
様々なテーマでプログラミングに囚われずに取り組んでいきます。
詳しくはこちら ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
プログラミングサロン 月1だけのプログラミング学習塾協力応援 / 支援者の集い
トラストヒューマン
http://trusthuman.co.jp/
私たちは何よりも信頼、人と考えてます。「コンサルティング」と「クリエイティブ」の両角度から「人材戦略パートナー」としてトータル的にサポートします!!
- 投稿日:2020-02-19T10:11:32+09:00
【iOS】UIButtonに波紋エフェクトを付ける
Ripple effectが付いたUIButtonを紹介します。
コード
タッチ発生時に座標を取り、円形のUIViewを拡大・うすめながら表示します。
import UIKit class RippleButton: UIButton { override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { super.touchesBegan(touches, with: event) drawRipple(touch: touches.first!) } private func drawRipple(touch: UITouch) { let rippleView = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 200)) rippleView.layer.cornerRadius = 100 rippleView.center = touch.location(in: self) rippleView.transform = CGAffineTransform(scaleX: 0.01, y: 0.01) rippleView.backgroundColor = UIColor(red: 0/255, green: 0/255, blue: 0/255, alpha: 0.4) addSubview(rippleView) UIView.animate( withDuration: 0.5, delay: 0.0, options: UIView.AnimationOptions.curveEaseIn, animations: { rippleView.transform = CGAffineTransform(scaleX: 1, y: 1) rippleView.backgroundColor = .clear }, completion: { (finished: Bool) in rippleView.removeFromSuperview() } ) } }使い方
RippleButtonを使うだけです。
Storyboardから使う場合は、UIButtonを置いたあとにカスタムクラスを設定して下さい。
なお、以下のようにボタンを角丸などにする場合はclipsToBounds = true
を設定しないと波紋がはみ出ます。
- 投稿日:2020-02-19T08:04:28+09:00
【Swift】モバイルでのJWT(JSON Web Token)の扱い方を考える
下記の記事を読んでいて
JWTについての知識や扱い方などを十分に把握できていないと感じ
まとめてみました。
https://tech.just-eat.com/2019/12/04/lessons-learned-from-handling-jwt-on-mobile/JWTについて
JWTとは?
JSON Web Tokenの略で
RFC7519で定義されている
JSONをベースとしたアクセストークン※のためのオープン標準です。※
アクセストークンはリソースに直接アクセスするために必要な情報を保持しています。
クライアントがリソースを管理するサーバにアクセストークンを渡すときに
サーバはそのトークンに含まれている情報を使用して
クライアントが認可したものかを判断します。JWTの特徴
- コンパクトな設計でURLセーフ
- RFC7515とRFC7516のどちらかに準拠する必要がある
- 当事者の一方または両方の秘密鍵により署名されており、発行されたトークンが正規のものが確認可能
- トークン内に任意の情報を保持可能
- 有効期限があり、期限切れのトークンはリフレッシュする必要がある
JWTの構成
「ヘッダー」と「ペイロード」と「署名」で構成されます。
最終的な形は
この3つの要素をbase64変換してドットで区切った形式になります。eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb2dnZWRJbkFzIjoiYWRtaW4iLCJpYXQiOjE0MjI3Nzk2Mzh9.gzSraSYS8EXBxLN_oWnFSRgCzcmJmMjLiuyu5CSpyHIhttps://ja.wikipedia.org/wiki/JSON_Web_Token
有効期限
ペイロードに
exp
と呼ばれる標準フィールドがあり
この期限以降はトークンを受け付けられなくしなければなりません。トークンのリフレッシュにまつわる問題
トークンのリフレッシュはどのアプリでも共通して扱われていますが
上手く実装しないとユーザが頻繁にログアウトしてしまうなどの問題が発生します。例えば下記のような場合があります。
同時並行で2つのAPIを呼ぶ
すでにトークンが有効期限切れの状態で
2つのAPIを同時並行で呼び出した場合
2つのリクエストが競合状態になり1回目のリクエストで
リフレッシュトークン(※)を使用してアクセストークンが更新されているのに2回目のリクエストでは
アクセストークンの更新リクエストを呼び出した時点では最新であったものの
1回目のリクエストですでに古くなってしまったリフレッシュトークンを使用して
アクセストークンを更新しようとしているためサーバがステータスコード401を返し
アプリはログアウトのような処理をしてしまう
という現象が起きます。下記の図のようなイメージです。
※
リフレッシュトークンは
新しいアクセストークンを取得するために必要な情報を保
持しています。
特定リソースにアクセスする際にアクセストークンが必要な場合は
クライアントは認証サーバが発行する新しいアクセストークンを取得するために
リフレッシュトークンを使用します。一般的にはアクセストークンの期限が切れた後に新しいものを取得したり
初めて新しいリソースにアクセスするときに使用します。起動時でなくても
直前でトークンの有効期限が切れた場合も同様です。時刻の同期が取れていない
クライアントとサーバで時刻がかなり離れている場合
クライアントでは有効なトークンを保持しているはずなのに
なぜか有効期限切れと判定されてしまいます。これは
原因が特定しづらいとても複雑な状態を生み出します。よくある処理はちょっとコストが高い
よく見られる処理方法として
APIをリクエストしてみて
ステータスコード401が返却されたらトークンをリフレッシュするというものですが
こうすると
すでに期限切れのアクセストークンを使用した
リクエストをしてしまうことに加え
呼び出し回数が増えたことで
リクエスト間の競合状態を生み出す可能性も高めます。対処方法
上記のような余計なリクエストを減らすために
下記のような方法が挙げられます。
- ローカルのアクセストークンの有効期限をチェックして有効ならばそのまま使用する。
- 無効ならばアクセストークンをリフレッシュする。その際に1度に1つのリクエストしかサーバに送れないように制御をする。
実装
では具体的にどういう処理が必要になるのか?
まずは処理の流れを見てみます。
上記のJWT取得プロセスのところにスレッドの制御を加えます。
Grand Central Dispatch (GCD)の
DispatchQueue
とDispatchSemaphore
を用います。
https://developer.apple.com/documentation/DISPATCHtypealias Token = String typealias AuthValue = Token struct AuthInfo { let accessToken: Token let refreshToken: Token let expiredDate: Date var isValid: Bool { expiredDate.compare(Date()) == .orderedDescending } } enum AuthError: Error { case missingAuthInfo case clientError var isClientError: Bool { if case .clientError = self { return true } return false } } protocol TokenRefreshing { func refreshAccessToken(_ refreshToken: Token, completion: @escaping (Result<AuthInfo, AuthError>) -> Void) } protocol AuthStore { var authInfo: AuthInfo? { get } func save(authInfo: AuthInfo) func clearAuthInfo() } class AuthProvider { private let tokenRefreshingAPI: TokenRefreshing private let store: AuthStore private let queue = DispatchQueue(label: "AuthProvider", qos: .userInteractive) private let semaphore = DispatchSemaphore(value: 1) init(tokenRefreshingAPI: TokenRefreshing, store: AuthStore) { self.tokenRefreshingAPI = tokenRefreshingAPI self.store = store } func getValidAuthInfo(completion: @escaping (Result<AuthValue, Error>) -> Void) { queue.async { [weak self] in self?.getValidAuthInfoInMutualExclusion(completion: completion) } } private func getValidAuthInfoInMutualExclusion(completion: @escaping (Result<AuthValue, Error>) -> Void) { // 以下の処理へのアクセスを1度に1つだけにする semaphore.wait() // ローカルにAuthInfoがあるかどうかを確認する guard let authInfo = store.authInfo else { semaphore.signal() completion(.failure(AuthError.missingAuthInfo)) return } // ローカルのAuthInfoが有効かどうかを確認する if authInfo.isValid { self.semaphore.signal() completion(.success(authInfo.accessToken)) return } // リモートからAuthInfoを取得する tokenRefreshingAPI.refreshAccessToken(authInfo.refreshToken) { [weak self] result in guard let self = self else { return } switch result { case .success(let authInfo): self.store.save(authInfo: authInfo) self.semaphore.signal() completion(.success(authInfo.accessToken)) case .failure(let error) where error.isClientError: self.store.clearAuthInfo() self.semaphore.signal() completion(.failure(error)) case .failure(let error): self.semaphore.signal() completion(.failure(error)) } } } }認証が必要なAPIリクエストは
AuthProvider
からAuthValue
(今回の場合はToken
)を取得し必要な箇所に設定します。
(多くの場合はヘッダーにAuthorization: Bearer <jwt-token>
と設定するかと思います。)
AuthProvider
はアプリ全体で同じqueueを使用するためにclass
にして参照を共有するようにします。
getValidAuthInfo
では
シリアルなDispatchQueueを使用してメソッド呼び出しの順番を制御しています。
getValidAuthInfoInMutualExclusion
はDispatchSemaphoreを使用しています。これは内部でトークンをリフレッシュする非同期のAPIを呼び出しており
仮にこのリフレッシュ処理に時間がかかってしまうと
次のリクエストのリフレッシュAPIが先に呼び出されてしまう場合があります。もう少し詳細に言うと
仮にDispatchSemaphoreでの制御がない場合
tokenRefreshingAPI.refreshAccessToken
はasyncなので
コールバックが返ってくる前にgetValidAuthInfoInMutualExclusion
メソッドを抜けます。そうすると
getValidAuthInfo
のqueue.asyncのブロックを抜けて
次のリクエストによるgetValidAuthInfoInMutualExclusion
が呼ばれて
中のAPIも呼ばれてしまいます。これをDispatchSemaphoreのvalueを1にすることで
semaphore.wait
を呼んでから
semaphore.signal
が呼ばれるまでの処理を
1度に1つのスレッドしかアクセスできないようにしています。
AuthInfo
をクリアするのには注意が必要で
例えば他のデバイスでパスワードの変更をおこなっていて
すでに無効なリフレッシュトークンを使ってリクエストを送っていた場合など
認証情報が無効であるときに限定しています。単純なエラーの場合にクリアしてしまうと
通信が失敗したら突然ログアウトをしてしまった
のような印象を持たれてしまうかもしれません。JWTのペイロードを正しくパースする方法
たいていの場合は問題ないものの
base64へのエンコード処理の中には
paddingの文字が必要ないものがある一方で
Foundationでは必須となっています。StackOverFlowでも↓のような回答があります。
https://stackoverflow.com/questions/36364324/swift-base64-decoding-returns-nil/36366421#36366421これに対処するためには下記のような置換が必要になります。
https://github.com/kylef/JSONWebToken.swift/blob/master/Sources/JWT/Base64.swiftRFCにも記載があります。
https://tools.ietf.org/html/rfc7515#appendix-C複数のJWTを使用する
例えば
ログインユーザと匿名ユーザ(ログインしていないで利用しているユーザ)で
異なるJWTを使ってAPIのリクエストをする場合があるとします。これを
AnonymousJWT(匿名ユーザ用JWT)
UserJWT(ユーザ用JWT)
とします。初めてアプリを使用するユーザが
一番最初のAPIを呼び出した時に
AnonymousJWTを取得してローカルに保存しログイン時に
UserJWTを取得してローカルに保存するとします。この時に考えられるユーザの状態として3パターン考えられます。
未認証
匿名認証済
ユーザ認証済図にしてみると下記のようなイメージになります。
このような場合に
- UserJWTがある場合はUserJWTを利用するとレスポンスでユーザに合わせてカスタムしやすい
- ログアウト時に有効なAnonymousJWTを残しておけばAnonymousJWTを再取得する処理を省ける
など
より効果的に使用することもできます。まとめ
JWTについて見てみました。
普段はFirebaseに任せていることが多かったので
詳細な中身を見ていくことで
単純にパースができないJWTがあるなど
色々な発見がありました。また
トークンのリフレッシュ処理をきちんと制御しないと
予期せぬ動作をさせてしまう可能性がある点は
十分に気をつけないといけないなと感じました。今回はJWTの処理がテーマでしたが
非同期処理による競合状態を起こしてしまうことは
他でも起こりうると思いますので
意識しておくとよいかもしれませんね?何か間違いなどございましたらご指摘いただけますとうれしいです??♂️
- 投稿日:2020-02-19T05:56:28+09:00
[flutter_driver] Flutter統合テストでdriverアクションがフリーズしてしまう問題を解決する
CircularProgressIndicatorなどのループアニメーションを含むWidgetがUIにあると、
driver.tap()
やdriver.screenshot()
などのdriverへのアクションはフリーズします。
これは、flutter_driverにはUIの更新が終わるまでUIへの操作を待機させるframe sync
という機構が備わっているからです。例
test('test', () async => { // UIにプログレスバーなどのループアニメーションがあると実行されない // =>テストがタイムアウト await driver.tap(find.byValueKey('key')); });解決方法
runUnsynchronized
でframe sync
を無効にすればUIの更新終了を待たずに実行することができますtest('test', () async { await driver.runUnsynchronized(() async { // UIが更新途中でも実行される await driver.tap(find.byValueKey('key')); }); });
- 投稿日:2020-02-19T02:58:38+09:00
[Flutter] LayoutBuilderが無限にビルドされてしまうのを防ぐには
Widgetのサイズを取得するために使用されるLayoutBuilderは、その子Widgetがビルドされると、それに伴ってビルドされてしまいます。
したがって、FutureBuilderなど動的にUIを変更するWidgetをLayoutBuilderの子孫に使用するときは注意が必要です。例えば以下のWidgetはTextがビルドされるごとにLayoutBuilderがリビルドされ、無限にビルドされてしまいます。
@override Widget build(BuildContext context) => LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) => FutureBuilder<Ranking>( future: future, builder: (BuildContext context, AsyncSnapshot<Ranking> snapshot) { if (snapshot.hasError) return Text('error'); if (!snapshot.hasData) return Text('loading'); return Text('done'); }, ));このことはFutureBuilderのリファレレンスの冒頭に書かれています。
The future must have been obtained earlier, e.g. during State.initState, State.didUpdateConfig, or State.didChangeDependencies. It must not be created during the State.build or StatelessWidget.build method call when constructing the FutureBuilder. If the future is created at the same time as the FutureBuilder, then every time the FutureBuilder's parent is rebuilt, the asynchronous task will be restarted.
StatefullWidgetなどを用いてfutureが毎度作成されないようにするなどの対応が必要です。