20200219のiOSに関する記事は8件です。

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-573036332

Note: 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-localstorage

CordovaでXHRがエラーになる

CordovaのWebアプリ(index.html)はfile://スキームでアクセスします。そのため、例えばfile://スキームからインターネット上で公開されているHTTPSのAPIにアクセスしようとすると、オリジンが異なるためエラーになります。
UIWebViewではこの制約は無視されていましたが、WKWebViewではブラウザと同様にクロスドメイン通信を行う場合はCORSに対応する必要があります。

対応方法は以下記事などを参照してください。
https://qiita.com/tomoyukilabs/items/81698edd5812ff6acb34

WKWebViewのUserAgentを変更する方法

以下の通り、3つの方法があります。

  1. UserDefaultsで設定
  2. WKWebView#customUserAgent で設定
  3. 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-L65

Cordova以外でハマった点

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%8B

iOS11でバグは修正されているため、対応するOSをiOS11以降とするのも手です。
2020/02/19時点で94%のiPhoneユーザーはiOS12以降を利用しており、現実的な手段だと思います。

WKWebViewに移行すると良いこともある

対応方法だけ書くと面倒なだけに感じますが、WKWebViewにすることで大きなメリットもあります。

例えば、引っ張って更新するUIを実装するとflickingするバグなどはWKWebViewにすると直ります。UIWebViewの謎挙動に悩まされてきた人多いんじゃないでしょうか(私です)

その他にも全体的にパフォーマンスが向上したり(特にスクロール)、機能的にもSign in with Appleが動くなど恩恵があるので、是非この機会に頑張って対応しましょう!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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の作り方

  • File>New>File... を選択する。
    cap01.png

  • Resouce の中にある SceneKit Catalog を選択して Next を押す。
    cap02.png

  • 名前をつけて保存(ここでは art.scnassets としました)する。
    cap03.png

  • できたフォルダ(art.scnassets)を選択した状態で、下のように表示されていればOK。
    cap04.png

ちなみに

新規Projectを作るときに、Gameを選択して Game Techinology:SceneKitを選択した場合は、最初から art.scnassets が作成されて飛行機のモデルのシーン(ship.scn)とそのテクスチャ(texture.png)が入っています。
cap05.png
cap06.png
cap07.png

まとめ

ARKit、ほぼ初めてなので、いろいろわからないこと多いです。ちょっとずつ試してみる予定です。

以下の記事も参考になりました。ありがとうございます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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.sh
set -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でハマった時のデバッグ

デバッグに効果的だった方法を書き残しておきたいと思います。

  1. ローカルとCIの環境がバラバラにならないように、パッケージ管理の仕組み(Bundlerなど)を使う
  2. CIのログを読んで、ローカルで同じコマンドを実行してみる

特に2番目は大事で、CIはVM立ち上げるのが遅いのでちょっと変えて試してがやりにくくて、デバッグはし辛いです。なので、手元で同じコマンドを打って、どのようなコマンド,オプションの渡し方をすればこの問題は解決するのか。というところから逆算でCIのステップを直していくのが効果的でした。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

今更聞けない「バージョン判定」

はじまり

さまざまな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-C
float iOSVersion = [[[UIDevice currentDevice] systemVersion] floatValue];
if (iOSVersion > 9.9f) {
    // iOS10以降の場合
} else {
    // iOS9以前の場合
}

上記のように、iOSのバージョン判定で、直接値が書く方法でもこちらは、有効です。

iOS 8.0 で NSProcessInfo に追加されたメソッドを使用すると簡単にバージョンの判定が行える。

Objective-C
NSOperatingSystemVersion 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-C
if ( floor(NSFoundationVersionNumber) > NSFoundationVersionNumber_iOS_9_x_Max ) {
    // iOS10以降の場合
} else {
    // iOS9以前の場合
}

iOS10以降の場合

Objective-C
if (@available(iOS 13.0, *)) {
    // iOS13以降の場合
} else {
    // iOS13未満の場合
}

iOS13以降とiOS12以前の場合で切り替えることが可能です。
特に、Popoverの時などのレイアウト変更の時はよく使いました。
こちらの苦労話は、またどこかで書きたいと思います。

Swift 切り替える場合 の 例
Swift
if floor(NSFoundationVersionNumber) > NSFoundationVersionNumber_iOS_9_x_Max {
    // iOS10以降の場合
} else {
    // iOS9以前の場合
}

iOS10以降の場合

Swift
if #available(iOS 10.0, *) {
    // iOS10以降の場合
} else {
    // iOS9以前の場合
}

参考:iOS 7 UI Transition Guide

関連記事

  • マクロの書き方
  • Popover
  • ブロック構文
  • バーションアップ
  • Objective-C 一覧
  • Swfit 一覧

制作チーム:サンストライプ

http://sunstripe-main.jp/
(月1WEBコンテンツをリリースして便利な世の中を作っていくぞ!!ボランティアプログラマー/デザイナー/イラストレーター/その他クリエイター声優募集中!!)

地域情報 THEメディア

THE メディア 地域活性化をテーマに様々なリリース情報も含め、記事をお届けしてます!!
https://the.themedia.jp/

ゼロからはじめる演劇ワークショップ

多様化の時代に向けて他者理解を鍛える

https://workshop.themedia.jp/

プログラミングワークショップ・ウェブ塾の開講!!!

様々なテーマでプログラミングに囚われずに取り組んでいきます。
詳しくはこちら ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
プログラミングサロン 月1だけのプログラミング学習塾

協力応援 / 支援者の集い

トラストヒューマン

http://trusthuman.co.jp/
私たちは何よりも信頼、人と考えてます。

「コンサルティング」と「クリエイティブ」の両角度から「人材戦略パートナー」としてトータル的にサポートします!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【iOS】UIButtonに波紋エフェクトを付ける

Ripple effectが付いたUIButtonを紹介します。
ripple.gif

コード

タッチ発生時に座標を取り、円形の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を置いたあとにカスタムクラスを設定して下さい。
image.png
なお、以下のようにボタンを角丸などにする場合はclipsToBounds = trueを設定しないと波紋がはみ出ます。
image.png

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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セーフ
  • RFC7515RFC7516のどちらかに準拠する必要がある
  • 当事者の一方または両方の秘密鍵により署名されており、発行されたトークンが正規のものが確認可能
  • トークン内に任意の情報を保持可能
  • 有効期限があり、期限切れのトークンはリフレッシュする必要がある

JWTの構成

「ヘッダー」と「ペイロード」と「署名」で構成されます。

スクリーンショット 2020-02-17 14.32.30.png

最終的な形は
この3つの要素をbase64変換してドットで区切った形式になります。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb2dnZWRJbkFzIjoiYWRtaW4iLCJpYXQiOjE0MjI3Nzk2Mzh9.gzSraSYS8EXBxLN_oWnFSRgCzcmJmMjLiuyu5CSpyHI

https://ja.wikipedia.org/wiki/JSON_Web_Token

有効期限

ペイロードにexpと呼ばれる標準フィールドがあり
この期限以降はトークンを受け付けられなくしなければなりません。

トークンのリフレッシュにまつわる問題

トークンのリフレッシュはどのアプリでも共通して扱われていますが
上手く実装しないとユーザが頻繁にログアウトしてしまうなどの問題が発生します。

例えば下記のような場合があります。

同時並行で2つのAPIを呼ぶ

すでにトークンが有効期限切れの状態で
2つのAPIを同時並行で呼び出した場合
2つのリクエストが競合状態になり

1回目のリクエストで
リフレッシュトークン(※)を使用してアクセストークンが更新されているのに

2回目のリクエストでは
アクセストークンの更新リクエストを呼び出した時点では最新であったものの
1回目のリクエストですでに古くなってしまったリフレッシュトークンを使用して
アクセストークンを更新しようとしているため

サーバがステータスコード401を返し
アプリはログアウトのような処理をしてしまう
という現象が起きます。

下記の図のようなイメージです。

jwt.png


リフレッシュトークンは
新しいアクセストークンを取得するために必要な情報を保
持しています。
特定リソースにアクセスする際にアクセストークンが必要な場合は
クライアントは認証サーバが発行する新しいアクセストークンを取得するために
リフレッシュトークンを使用します。

一般的にはアクセストークンの期限が切れた後に新しいものを取得したり
初めて新しいリソースにアクセスするときに使用します。

起動時でなくても
直前でトークンの有効期限が切れた場合も同様です。

時刻の同期が取れていない

クライアントとサーバで時刻がかなり離れている場合
クライアントでは有効なトークンを保持しているはずなのに
なぜか有効期限切れと判定されてしまいます。

これは
原因が特定しづらいとても複雑な状態を生み出します。

よくある処理はちょっとコストが高い

よく見られる処理方法として

APIをリクエストしてみて
ステータスコード401が返却されたらトークンをリフレッシュする

というものですが

こうすると
すでに期限切れのアクセストークンを使用した
リクエストをしてしまうことに加え
呼び出し回数が増えたことで
リクエスト間の競合状態を生み出す可能性も高めます。

対処方法

上記のような余計なリクエストを減らすために
下記のような方法が挙げられます。

  • ローカルのアクセストークンの有効期限をチェックして有効ならばそのまま使用する。
  • 無効ならばアクセストークンをリフレッシュする。その際に1度に1つのリクエストしかサーバに送れないように制御をする。

実装

では具体的にどういう処理が必要になるのか?

まずは処理の流れを見てみます。

jwt-Page-2.png

上記のJWT取得プロセスのところにスレッドの制御を加えます。

Grand Central Dispatch (GCD)のDispatchQueueDispatchSemaphoreを用います。
https://developer.apple.com/documentation/DISPATCH

typealias 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.swift

RFCにも記載があります。
https://tools.ietf.org/html/rfc7515#appendix-C

複数のJWTを使用する

例えば
ログインユーザと匿名ユーザ(ログインしていないで利用しているユーザ)で
異なるJWTを使ってAPIのリクエストをする場合があるとします。

これを
AnonymousJWT(匿名ユーザ用JWT)
UserJWT(ユーザ用JWT)
とします。

初めてアプリを使用するユーザが

一番最初のAPIを呼び出した時に
AnonymousJWTを取得してローカルに保存し

ログイン時に
UserJWTを取得してローカルに保存するとします。

この時に考えられるユーザの状態として3パターン考えられます。

未認証
匿名認証済
ユーザ認証済

図にしてみると下記のようなイメージになります。

jwt-Page-3.png

このような場合に

  • UserJWTがある場合はUserJWTを利用するとレスポンスでユーザに合わせてカスタムしやすい
  • ログアウト時に有効なAnonymousJWTを残しておけばAnonymousJWTを再取得する処理を省ける

など
より効果的に使用することもできます。

まとめ

JWTについて見てみました。

普段はFirebaseに任せていることが多かったので
詳細な中身を見ていくことで
単純にパースができないJWTがあるなど
色々な発見がありました。

また
トークンのリフレッシュ処理をきちんと制御しないと
予期せぬ動作をさせてしまう可能性がある点は
十分に気をつけないといけないなと感じました。

今回はJWTの処理がテーマでしたが
非同期処理による競合状態を起こしてしまうことは
他でも起こりうると思いますので
意識しておくとよいかもしれませんね?

何か間違いなどございましたらご指摘いただけますとうれしいです??‍♂️

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[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'));
});

解決方法

runUnsynchronizedframe syncを無効にすればUIの更新終了を待たずに実行することができます

test('test', () async {
  await driver.runUnsynchronized(() async {
    // UIが更新途中でも実行される
    await driver.tap(find.byValueKey('key'));
  });
});
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[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が毎度作成されないようにするなどの対応が必要です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む