【iOS】Firebase RemoteConfig で作成する強制ダイアログ機構

はじめに

Firebase Advent Calendar 12日目です。本稿では、Firebaseを使用した強制ダイアログ表示に関して記載します。ここで、強制ダイアログと表記しているのは、いわゆる強制バージョンアップダイアログに代表されるダイアログを表示し、それ以上のユーザーのアプリ使用を防ぐ機能を指しています。

具体的には、アプリケーションの起動時や復帰時にダイアログを表示し、アプリケーションのそれ以上の操作を行わないようにするものです。リリースしたアプリに致命的な不具合があった場合にそのバージョンを使用するユーザーを限りなく少なくするために強制バージョンアップダイアログを表示したり、サーバー側の何らかの不具合によりアプリ使用を防ぎたい場合に使用することが多いかと思います。

環境設定

以下の環境を使用しています。

  • Firebase RemoteConfig 6.12.0
  • Swift 5.1
  • Xcode 11.3
  • iOS 13.2.3

なお、本稿で記載している実装をまとめたサンプルを以下のようにGitHubに上げています。

イデアと実装

本章でどのようなアイデアに基づくのかと、具体的な実装に言及します。

概要

FirebaseのRemoteConfigを使用することで以下を管理します。

  • 対象アプリバージョン(本バージョン未満の場合にダイアログを表示する)
  • ダイアログのタイトル
  • ダイアログのメッセージ
  • ダイアログのOKボタン押下時の遷移先URL(App Store や Sorryページ)

それぞれを一項目として管理しても良いでしょうし、JSONでひとまとまりにしても良いかと思いますが本稿ではJSONとして格納した場合に関して記載します。

アプリ側ではFirebaseで設定されたバージョンと現在のアプリバージョンを比較し、現在のバージョンがFirebaseの設定バージョン未満だった場合にダイアログを表示するような機構を導入します。上記により、本稿が目的とする機構を実現することができます。

Firebase側の設定

Firebase側の設定に関して記載します。「概要」にても記載しました通り、本稿ではJSON形式でデータの登録を行います。パラメータは force_alert_information命名しました。行いたい挙動としては、該当バージョンアップ未満のアプリの場合にアラートを表示し、OKボタン押下でなんらかのWebページに遷移、ストアに遷移、もしくは何もせずにアラートを表示し続けた状態とするとします。これらを実現するために必要な情報は下記です。

  • title - アラートのタイトルに使用
  • message - アラートのメッセージに使用
  • version - アラートの表示可否で使用する基準バージョン
  • url - okボタン押下時の遷移先URL

実際に設定を行います。FirebaseのプロジェクトページのRemoteConfigからパラメータを追加することができます。 パラメータを追加 > 右側の {}を押下することでJSON形式でのパラメータ追加が可能になります。 f:id:Iganin:20191208215732p:plain f:id:Iganin:20191208215855p:plain

JSON形式でのパラメータ追加時は専用のEditorが表示され、JSON形式に添わない場合はエラーが表示され保存ができません。そのためJSON形式に添わない文字列を保存してしまい不具合が発生してしまう、というような事態は避けられるようになっています。

f:id:Iganin:20191208220051p:plain

RemoteConfigのWrapperクラスの作成

アプリ側でのRemoteConfigを扱うwrapperクラスを作成します。具体的な設定・導入までの手順は公式ドキュメントをご確認ください。本稿では、導入までは完了している前提で記載を進めます。

実装概要

以下に作成したクラスを記載します。コードにコメントとして直接詳細を記述していますが、主な注意点は下記です。

  • Debug時の設定
    • 実プロダクトで毎回fetchは取得上限回数に当たる可能性があるため厳しいですが、Debug時は即座に確認したいことが多いため、debugとreleaseで取得間隔を切り分けdebug時は0としています
  • 取得間隔
    • 1時間あたりの取得回数上限があるため、前回取得時より10分の間隔を設けています
  • デフォルト値の扱い
    • plistで扱う方法もあるかと思いますが、コード内で完結させたかったためenumでパラメータキーを定義し、デフォルト値を持たせています。
import Foundation
import FirebaseRemoteConfig

// 取得するパラメータを定義します
enum RemoteConfigParameterKey: String, CaseIterable {
    case forceAlertInformation = "force_alert_information"
    
    var defaultValue: NSObject? {
        switch self {
        case .forceAlertInformation: return ForceAlertInformation.defaultValue()
        }
    }
}

// RemoteConfigの設定用Protocolです
protocol RemoteConfigServiceProtocol {
    func fetchAllData()
}

// RemoteConfigのプロパティ取得用Protocolです
protocol RemoteConfigPropertyProvider {
    func getForceAlertInformation() -> ForceAlertInformation?
}

final class RemoteConfigService: RemoteConfigServiceProtocol {
    
    // Protocolを使用してDI時にモックとの入れ替えが可能なようにインスタンスとして扱うようにしています
    // Singletion
    static let shared = RemoteConfigService()
    
    private init() {
        remoteConfig = RemoteConfig.remoteConfig()
        // releaseビルドではない場合は取得感覚を0としています
        if !AppUtility.isRelease {
            remoteConfig.configSettings.minimumFetchInterval = 0.0
        }
        
        // 全てのデータを取得する前提で定義されているパラメータキーに対するデフォルト値を全て入れています
        remoteConfig.setDefaults(makeDefaultValues(forKeys: RemoteConfigParameterKey.allCases))
    }
    
    // MARK: - Prpoerty
    private let remoteConfig: RemoteConfig
    private var expirationDuration: TimeInterval {
        // debugビルドでは即時反映, releaseビルドでは一定時間あけるようにします
        switch AppUtility.buildType {
        case .debug: return 0.0
        case .release: return 10 * 60 // 10分間
        }
    }
    
    // MARK: - Function
    func fetchAllData() {
        remoteConfig.fetch(withExpirationDuration: expirationDuration) { [weak self] (fetchStatus, error) in
            guard error == nil else { return }
            
            switch fetchStatus {
            case .success: self?.remoteConfig.activate(completionHandler: { error in
                if let error = error {
                    print(error.localizedDescription)
                }
            })
            case .failure, .noFetchYet, .throttled: break
            @unknown default: break
            }
        }
    }
    
    // RemoteConfigParameterKeyで定義したKeyに対してデフォルト値を決定し代入します
    private func makeDefaultValues(forKeys keys: [RemoteConfigParameterKey]) -> [String: NSObject] {
        var defaultValues = [String: NSObject]()
        keys.forEach { key in
            // この部分は後ほど可能な限り修正します
            if let defaultValue = key.defaultValue {
                defaultValues[key.rawValue] = defaultValue
            }
        }
        return defaultValues
    }
}

extension RemoteConfigService: RemoteConfigPropertyProvider {
    private func getProperty(for key: RemoteConfigParameterKey) -> RemoteConfigValue? {
        return remoteConfig.configValue(forKey: key.rawValue)
    }
    
    func getForceAlertInformation() -> ForceAlertInformation? {
        // この部分は後ほど可能な限り修正します
        guard let data = getProperty(for: .forceAlertInformation)?.dataValue else { return nil }
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        return try? decoder.decode(ForceAlertInformation.self, from: data)
    }
}

上記で使用しているForceAlertInformationの実装は下記です。

import Foundation

struct ForceAlertInformation: Codable {
    let title: String
    let message: String
    let version: String
    let url: URL?
}

extension ForceAlertInformation {
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        title = try container.decode(String.self, forKey: .title)
        message = try container.decode(String.self, forKey: .message)
        version = try container.decode(String.self, forKey: .version)
        let urlString = try? container.decode(String.self, forKey: .url)
        url = urlString == nil ? nil : URL(string: urlString!)
    }
}

extension ForceAlertInformation: RemoteConfigDefaultValueProvidable {
    static func defaultValue() -> NSObject? {
        let defaultValue = ForceAlertInformation(
            title: "確認",
            message: "新しいバージョンのアプリがあります。アップデートをお願いします。",
            version: "1.0.0",
            url: nil)
        let encoder = JSONEncoder()
        encoder.keyEncodingStrategy = .convertToSnakeCase
        guard let defaultData = try? encoder.encode(defaultValue) else { return nil }
        return NSData(data: defaultData)
    }
}

RemoteConfigの設定値の取得タイミング

applicationDidBecomeActive:のタイミングで取得するよう実装しています。このタイミングで行うことにより、アプリがタスクキルされていない場合でも background <-> forgroundの遷移で新しい値を取得・反映させることが可能です。

AppDelegate {
    func applicationDidBecomeActive(_ application: UIApplication) {
        // foreground <-> background遷移時に状態の変更を反映できるよう
        // このタイミングでRemoteConfigの設定値取得を行います
        RemoteConfigService.shared.fetchAllData()
    }
}

アプリ側の強制ダイアログ表示の実装

実装例

本稿のサンプルでは単純なMVC構成で作成しています。MVVMViperなど使用するアーキテクチャで具体的な実装は変わってくるかとは思いますが、その場合は初期表示される画面のViewModelPresenterで判定し表示を行うように実装するなどしていただけると良いかと思います。

バージョンの大小判定ロジック

Stringが比較演算を用いてそのまま比較可能なためバージョン判定時に下記のようにしてしまいがちです。

if currentVersion < criteriaVersion {
    // Alert表示
}

しかし、この方法ですと潜在的なバグを含んでしまいます。例えば、 currentVersion = "3.10.0"criteriaVersion = 3.9.0 の比較を行う場合本来であればcurrentVersionの方がcriteriaVersionよりも大きいためアラートが表示されて欲しくないですが、比較演算子で比較を行なってしまうと 3.10.0の方が3.9.0のより小さいと判定されアラートが表示されてしまいます。

これはおそらくですが、内部的に少数の比較となってしまっており、 3.1と3.9の比較が行われたことにより、3.10.0の方が3.9.0より小さいと判断されているために発生しています。そこで下記のように . で各桁の数字を分離し、各桁の数字の大小比較を行うことでバージョンの大小判定を行います。

struct VersionUtility {
    // 現在のバージョンと比較対象のバージョンを比べ強制アラートの表示が必要かを判定します
    func isForceAlertRequired(currentVersion: String, criteriaVersion: String) -> Bool {
        
        var currentVersionNumbers = currentVersion.components(separatedBy: ".").map { Int($0) ?? 0 }
        var criteriaVersionNumbers = criteriaVersion.components(separatedBy: ".").map { Int($0) ?? 0 }
        let countDifference = currentVersionNumbers.count - criteriaVersionNumbers.count
        
        // major versionから順に比較していき、同値でなくなった時に大小比較結果を返す
        for (current, criteria) in zip(currentVersionNumbers, criteriaVersionNumbers) {
            if current > criteria {
                return false
            } else if current < criteria {
                return true
            }
        }
        // 同値
        return false
    }
}

強制ダイアログの表示メソッド

OKボタン押下後にAlertが閉じないよう再度Alert表示のメソッドを呼び出しているところがポイントになります。

    func showForceAlert(information: ForceAlertInformation) {
        let alertController = UIAlertController(
            title: information.title,
            message: information.message,
            preferredStyle: .alert
        )
        let okAction = UIAlertAction(title: "OK", style: .default) { [weak self] _ in
            // okを押下後にAlertが非表示にならない用再度表示します
            self?.showForceAlert(information: information)
            if let url = information.url,
                UIApplication.shared.canOpenURL(url) {
                UIApplication.shared.open(url, options: [:], completionHandler: nil)
            }
        }
            
        alertController.addAction(okAction)
        present(alertController, animated: true, completion: nil)
    }

実際の判定処理

ViewControllerで行う実際の判定処理部分です。実プロダクトで使用する場合は、アラート表示後の処理を行うかどうか検討し、Bool値を返すようなメソッドに分割変更した方が良いかと思います。

    func showForceAlertIfNeeded() {
        guard let forceAlertInformation = remoteConfigPropertyProvider.getForceAlertInformation() else { return }
        
        let currentVersion = AppUtility.currentVersion
        let criteriaVersion = forceAlertInformation.version
        if VersionUtility().isForceAlertRequired(
            currentVersion: currentVersion, criteriaVersion: criteriaVersion) {
            showForceAlert(information: forceAlertInformation)
        }
    }

タイミング

ダイアログの表示の必要有無の判定および表示処理を実施するタイミングとして下記のライフサイクルを採用しています。

初期表示される画面のviewDidLoad

サンプルでは初期に表示されるViewControllerのViewDidLoad内で処理を行なっています。他の方法としては、例えば、 LauchScreenの表示後にLaunchScreenと同様の画面をUIViewControllerで表示し、そのviewDidLoadで判定を実施の後、ダイアログを表示するかTabBarControllerを表示するかだし分けるなどが考えられます。なお、後述する applicationDidBecomeActive のタイミングを拾えてしまい、同一の画面で2度実行されてしまう可能性あるため、そのような場合にはこのライフサイクルでの判定は不要になります。

    override func viewDidLoad() {
        super.viewDidLoad()
        setup()
        showForceAlertIfNeeded()
    }
applicationDidBecomeActive

アプリがタスクキルされていない場合も考慮して、このライフサイクルでの判定も行います。本メソッドをフォローすることでアプリのbackground <-> forgroundの遷移でも判定を行うことができます。具体的な実装はアプリのメイン画面(例えばRootViewController)にNotification経由でライフサイクルを注入するのが良いと思います。

    func setup() {
        NotificationCenter.default.addObserver(self, selector: #selector(applicationDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil)
    }
    
    @objc func applicationDidBecomeActive() {
        showForceAlertIfNeeded()
    }

合致するビジネス要件

RemoteConfig を使用した強制ダイアログ機構は大変便利なものではありますが、RemoteConfigの特性上ビジネス要件次第では採用が難しいパターンがあります。それはダイアログの表示を判断してから実際に表示されるまでのタイムラグを可能なかぎり0にしたい場合です。

Firebase RemoteConfigはその機能の仕様上、具体的には1時間あたりのフェッチ回数の上限があるという仕様上、前回の値所得時から新規の値取得時まで一定の期間設定値の再取得を行わないようになるパターンが多いです。したがって、このような場合にはダイアログに関するFirebase側の設定変更から実際のアプリ側への反映まで一定のタイムラグが発生してしまいます。

よって、設定変更から反映まで即座におこないたいという厳し目の要件の場合は合致しづらいですが、多少のタイムラグを許容できる場合は本記事の機構で十分実用にたりうると判断しています。

まとめ

強制ダイアログ表示はサービスメンテナンス時やなんらかの不具合によってアプリの全体的なバージョンアップが必要な時などに必要となり、実装しておいた方が良い機能かと思います。サーバーの初期通信などに強制ダイアログ表示機構の返却値等を入れた場合、サーバーがそもそも動作していない場合などにはダイアログ表示に必要な情報を取得することができません。

強制ダイアログ表示を行うための機構をサービスサーバーから分けることで、サーバーが機能していない場合でもメンテナンス中などの表示を行うことができます。機能要件の中で出てきた際には是非選択肢の一つとして検討してみてください。

参考資料

RemoteConfig

AdvcentCalendar

サンプル

【Swift】StructやClassスコープからSelfでstatic memberへのアクセスが可能になった

はじめに

Swiftではstructやenumおよびclass内からstaticなメンバーにアクセスする際には以下のようにClass名.staticMemberかtype(of: self).staticMemberとする方法がありました。

struct Sample  {
    static var hoge: Int = 0
    
    func sampleFunction() {
        // クラス名.staticMember
        let a = Sample.hoge
        // type(of: self).staticMember
        let b = type(of: self).hoge
    }
}

Swift5.1でこちらのアクセス方法に変更があったため記載します。

環境設定

以下の環境を使用しています。

  • Swift5.1

内容

structやclass, enum内のスコープにおいて、 Self.staticMemberでstaticなプロパティやメソッドにアクセスできるようになりました。 具体的には下記のようになります。

struct Sample {
    static var hoge: Int = 0
    static func fuga() {}
    
    func sampleMethod() {
        // こちらは従来からできました
        let sampleA = Sample.hoge
        let sampleB = type(of: self).hoge

        // swift5.1からこのアクセス方法が可能になっています
        let sampleC = Self.hoge
        
        Sample.fuga()
        type(of: self).fuga()

        // メソッドにも同様にアクセス可能です
        Self.fuga()
    }
}

class ClassSample {
    static var hoge: Int = 0
    class func fuga() {}
    
    func sampleMethod() {
        // classでも同様にアクセス可能です
        let sampleA = Self.hoge
        Self.fuga()
    }
}

まとめ

プロポーザルにも記載がありますが、 インスタンスのselfに対応する形でメソッドやプロパティにアクセスすることができ、理解しやすくなったのではないかなと感じています。参考に元のプロポーザルへのリンクを記載していますので是非ご確認ください。

参考

【iOS】iOS13でSystemフォントがTimes New Romanフォントに変更されることがある

概要

タイトル通りです。バグレポートでも上がっているようにUITextViewなどでsystemフォントを指定していた際に、アプリ実行時にTimes New Romanフォントに変更されてしまう、という事象が発生しているようです。

NSAttributedStringKey の .fontに明示的にUIFontを指定しないと起きるのではないか等原因に関して言及されていますが、はっきりとした理由はわからないと言うのが正直なところではないでしょうか。

事象と対応

私の場合ですが、Interface Builder上でUITextViewのplainの文字列にsystemフォントを設定していたところ、アプリ実行時に該当のエラーが発生しました。下記のようにコードから明示的にUIFontを指定することでバグを回避できています。

textView.font = UIFont.systemFont(ofSize: 16.0)

参考にネット上で見つけた関連した文書をまとめていますのでご参照ください。

所感

iOS 13 は本事象だけでなく様々なバグが発生しており、なかなか苦しいなというのが正直な感想です。。 アプリ内で文字フォントがおかしくなっていないか、念のため確認した方が良いかもしれません。

参考

【iOS】Password Autofillの実装

はじめに

iOS11からpassword autofillに対応することが可能になりました。 具体的には、パスワード新規作成時にiOSから提案される強力なパスワードを使用したり、keychainにユーザーID、パスワードのセットを記録し、アプリ使用時やWebサイトのログイン時に記録されたID・パスワードを使用することができます。

具体的な実装としては、下記のように対応することになります。

  • Apple Developer CenterにてAssociated Domainsにチェック
  • アプリのProject設定を変更
  • サーバーにapple-app-site-associationを配置
  • アプリ側のTextFieldのTypeに適切な値を設定する

本記事にて対応の概要と対応する中で学んだ注意点や補足事項を記載します。

環境設定

以下の環境を使用しています。

  • iOS 13.1.2
  • Xcode11.1.0

対応方法

以下で「はじめに」に記載した対応手順を概説します。

Apple Developer CenterにてAssociated Domainsにチェック

Apple Developer Center > Identifiersから自身のアプリを選択し、Associated Domainsにチェックをつける必要があります。この際にCapabilitiesの追加を行なったことによりProvisioning ProfileがInvalidateされますので、再作成を忘れないようにしてください。 f:id:Iganin:20191106201616p:plain

アプリのProject設定変更

アプリのProject設定を変更します。 Signing & Categoriesから「+Capability」ボタンを押下し、Associated Domainsを選択します。この際に、まだProjectにentitlementsファイルがない場合は新規生成されますので、適当なフォルダに格納したり、命名の変更を行なってください。 f:id:Iganin:20191106204028p:plain

検証環境と本番環境のConfigurationを別々にしているのが一般的かと思いますので、Build Settingsから各Configurationにあったentitlementsを設定するのが良いかと思います。

f:id:Iganin:20191106204311p:plain

associated domainsに追加したいdomainを記載します。webcredentials:<domain名>かユニバーサルリンクの対応を行いたい場合はapplinks:<doamain名>とすれば良いでしょう。なお、associated domainsは各entitlementsに対して設定できますので、検証環境と本番環境を分けることが可能です。

f:id:Iganin:20191107015327p:plain

サーバーにapple-app-site-associationを配置

アプリのprojectにて設定したドメインのサーバーにapple-app-site-associationファイルを追加します。ドメイン直下か.well-known配下に格納するようドキュメントには記載されています。例えば、ドメイン直下に格納した場合はhttps:///apple-app-site-associationでアクセスできるか確認します。(※ .well-known配下に格納した場合はhttps:///.well-known/apple-app-site-association)

実際のファイルの中身は下記のようになります。あまり情報はありませんでしたが、webcredentialsとapplinksの双方を記述して問題なさそうでした。ファイル自体はJSON形式で詳しい内容はAppleの公式ドキュメントに記載されています。

{
    "webcredentials": {
        "apps": [ "${TeamID}.${BundleID}" ]
    },
    "applinks": {
        "apps": [],
        "details": [{
            "appID": "${TeamID}.${BundleID}",
            "paths": ["*"]
            }]
    }
}

アプリ側のTextFieldのTypeに適切な値を設定する

ユーザー名、パスワードを入力するTextFieldに適切な値を設定します。Username, New Passwordの組み合わせで強力なパスワードの自動生成、 Username, Passwordの組み合わせでユーザー名, パスワードの自動入力が使用できます。同一画面上にUsernameがなく、password関連の入力のみの場合は上記は機能しないようでした。また、One Time Passwordに設定することでSMSに届いたOne Time Passwordを自動で入力値の候補として使用するようにできます。このあたりの内容の詳細は公式ドキュメントを参照ください。

以上の設定でPassword Autofillの機能追加が完了します。ただ、検証する中で引っ掛かった点がありましたので、いかに補足として記載します。

補足

上記設定をしてもPassword Autofillが機能しない場合のiPhone側確認事項

Password Autofillですが、保存先としてiCloud Keychainを使用しています。 そのため、有効化するためには以下の設定が必要です。

  • Apple Accountでログインする
  • 設定アプリ > Apple ID > iCloud > キーチェーン が オンになっている

Face IDやTouch IDの認証が求められた際の挙動

QuickType barからユーザーID・パスワードを選択した際に、Face IDかTouch IDの入力を求められます。 この際に、Face IDかTouch IDが表示された際にapplicationWillResignActive(_:)が呼ばれ、入力が完了した際にapplicationDidBecomeActive(_:)が呼ばれます。したがって、該当メソッド内で画面遷移などを実装している場合、意図しない挙動となる可能性が高いため注意が必要です。

Associated Domainsが登録されるタイミング

公式ドキュメントによると、アプリがiOSバイスにインストールされたタイミングでentitlementファイルのassociated domainsに登録されているドメインからassociationファイルをダウンロードし、アプリのIdentifierが登録されているか確認し、登録を行うようです。

この際にサーバーから500-599のエラーが返却された際はデフォルトでは3時間おきに8回まで通信リトライをするとのことです。

ドメインの関連付けに成功した後はアプリがデバイスから削除されるまでは関連付けは維持され続けるようです。サーバー側のassociationファイルの修正の反映を即座に確認したい場合はアプリの削除をすることが推奨されていました。 この辺りの詳細は* 公式ドキュメント - About the Password AutoFill Workflow を参照ください。

参考

以下参考文献です。公式のドキュメントから関連した文書を集めました。 やはりまずは一次情報に一通り当たるのが理解の近道かと思います。

【iOS】Push通知に必要なDeviceTokenの文字列取得に関して

はじめに

サーバーからのPush通知を実行するためには、サーバーにDeviceTokenの文字列を保持し、そのDeviceTokenを指定してAPNs(Apple Push Notification Service)に通信を行う必要があります。DeviceTokenはregisterForRemoteNotifications()の実行によって、application(_:didRegisterForRemoteNotificationsWithDeviceToken:)メソッドを通して通知されます。

しかし、通知されるDeviceTokenはData型であるため、Serverに送信するためには多くの場合、ここからString型に変換する必要があります。本記事では、変換に関してiOS13以降でのdescriptionメソッドに関する変更も交え記載します。

環境設定

以下の環境を使用しています。

  • iOS 13.1.2
  • Xcode11.1.0

iOS13以降での変更点

まずiOS13以降でdescriptionメソッドの返却値が変更になった点に関して記載します。 iOS13以前では(deviceToken as NSData).descriptionにて<1xxxxxx5 55xxxxca xxxxx572 00c3xxxx xxxxxxx5 92xxxx90 xxxxxxx7d bxxxxxxa>という文字列が取得できていました。サービスによってはここから以下のメソッドで<>と半角スペースを取り除いて送ったり、そのままServerに送信していたりしていたかと思います。

// <>と半角スペースを取り除く場合のメソッド例
let token = ((deviceToken as NSData).description.trimmingCharacters(in: CharacterSet(charactersIn: "<>")) as NSString).replacingOccurrences(of: " ", with: "")

しかし、iOS13以降でNSDataのdescriptionメソッドの返却値が下記のように変更になりました。{length=32,bytes=0x3dd8xxxxxxxx0be290cxxxxxxxxxx2e0...7xxxxxxxxxxxx0a}形式が大きく変わっていますし、文字列部分に...という省略が加わったことにより、deviceTokenの取得ができなくなりました。

AppStoreに出ているアプリでも本事象が原因と思われるバグ修正のリリースがいくつか見られます。

対応方法

純粋に文字列部分のみ取得したい場合は、@mono0926さんの以下のメソッドがシンプルで良いかと思います。12xxxx6a5xxxxxxxcad80xxxxxxxxxx3b93exxxxxx9xxxxxxx32xxxxxxb83967beこちらのような文字列が取得できます。

let token = deviceToken.map { String(format: "%.2hhx", $0) }.joined()

ただ、 NSDataのdescriptionメソッドの返却値を今までそのまま送っており、諸般の事情で<>や半角スペースを含んだ状態でDeviceTokenの文字列を送らなければならない場合もあるかと思います。その場合は、例えば以下のようにしてNSDataのdescriptionメソッドと同様の文字列を取得できます。

元の文字列を参考にDataを4bytes区切りで文字列に変換しています。最後に<>を付与して返却することでiOS13未満でのNSDataのdescriptionメソッドの返却値を再現しています。配列生成時にcapacityを考慮するとより処理効率が上がりそうですが、実行速度はそれほど問題にはならないと判断しこのままにしています。String(deviceToken: deviceToken)のように使用して文字列を取得できます。

extension String {
    /// DeviceTokenをDataから生成する初期化メソッドです。
    /// RemoteNotificationの実行に必要なTokenを生成します。
    ///
    /// - Parameter deviceToken: Dataから生成したDeviceToken文字列
    ///   例: <70a7ff15 cxxx3c44 bd0xxx2c f75xxxxb dxxxc23f ff14xxxe 6xxxx3f2 b6xxxxxb>
    init(deviceToken: Data) {
        let groupingUnit: Int = 4 // 4bytesずつ区切って文字列を作成する
        let groupCount: Int = 8 // 4bytes区切りの文字列を8つ作成する
        
        var deviceTokenStrings = [String](repeating: "", count: groupCount)
        deviceToken.enumerated().forEach { deviceTokenStrings[Int($0) / groupingUnit] += String(format: "%.2hhx", $1) }
        self = "<" + deviceTokenStrings.joined(separator: " ") + ">" // 前後を < > で囲う
    }
}

まとめ

Push通知の送信に必要なDeviceTokenの文字列取得に関して、<>や半角スペースを含めない場合の方法と含める場合の方法を記載しました。NSDataのdesctiptionメソッドを使用しており、iOS13以降で対応が必要になった場合はご参考にしていただけますと幸いです。

参考

【iOS】iOS13からSKProductsRequestのDelegateメソッドがメインスレッドで呼ばれなくなった

はじめに

In App Purchaseを実装する際にApp Storeに課金Productを登録し、アプリからそのプロダクトの情報を取得します。 具体的には、下記のようにSKProductsRequestを作成し、取得したい課金プロダクトのIdentifiersをSetで指定、通信結果を func productsRequest(request: SKProductsRequest, didReceiveResponse response: SKProductsResponse)で受け取ります。(※補足ですがRequestが解放されないよう強参照で保持するようAppleのDocumentに記載があります)

ただ、このメソッドの挙動がiOS13以降で変わったようなのでその内容を記載します。

private var request: SKProducstRequest?
request = SKProductsRequest(productIdentifiers: Set(productIdentifiers))
request?.delegate = self
request?.start()

環境設定

以下の環境を使用しています。

  • iOS 13.1.2
  • Xcode11.1.0

どう変わったか

端的にまとめると下記2つのメソッドがメインスレッドで実行されなくなりました。 具体的にはcom.apple.root.default-qos (concurrent)で実行されているようです。

func productsRequest(SKProductsRequest, didReceive: SKProductsResponse)
func request(SKRequest, didFailWithError: Error)

したがって今までメインスレッドで実行されることを想定していた実装の場合は注意が必要です。

対応方法

各メソッド内の処理を明示的にメインスレッドで実行するようにすれば大丈夫です。

func productsRequest(SKProductsRequest, didReceive: SKProductsResponse) {
    DispatchQueue.main.async {
        // なんらかの処理
    }
}

func request(SKRequest, didFailWithError: Error) {
    DispatchQueue.main.async {
        // なんらかの処理
    }
}

備考

自分で確かめていませんが、MacOSでの実行時は上記のメソッドは元々メインスレッドで実行されていなかったようです。iOS13以降ではProject CatalystによってiOSアプリとMacOSアプリのソースコードを共通化する仕組みが進められており、そのあたりの影響でこのような挙動の変更が発生したのかもしれません。(定かではないです)

参考

【iOS】アプリクライアント側での文字入力のValidation機構について(UITextField, UITextViewなど)

はじめに

サービスで使用するEmailアドレスやPasswordなどをアプリから入力する際に、アプリ側でValidationをかけることがあります。 そのような際に行う対応として直感的には以下のようにするかと思います。

extension ViewController: UITextFieldDelegate {
    func textFieldDidEndEditing(_ textField: UITextField) {
        if textField === textField {
            let text = textField.text
            
            if text?.isEmpty == true {
                errorLabel.text = "文字を入力してください。"
            } else if text?.contains("@") == false {
                errorLabel.text = "正しいメールアドレスの形式ではありません。"
            }
        }
    }
}

ただ、この方法では以下のような問題が発生します。

  • 同様なバリデーション処理が様々な箇所に分散してしまう。結果として修正漏れなどによる不具合が発生する
  • コードが冗長となり可読性が下がる

本記事では、Compositeパターンを適用することでValidation処理を構造的に作成し、 上記であげた問題に対処する方法を記載します。

環境設定

以下の環境を使用しています。

  • Xcode11.0.0
  • Swift 5.1.0

方法

Validator Protocolの作成

Validation用のエラーとValidation結果およびValidatorが準拠するべきProtocolを定義します。 ValidationResultはSwift.Resultを使用してもよかったのですが、Resultに指定した型Tの処理の部分が 冗長になるかと考えこの方法をとりました。

ValidationのErrorはLocalizedErrorに準拠することで errorDescription: String?を実装すると、 error.localizedDescriptionでエラーメッセージを表示させることができます。

Validatorはvalidate対象を引数とし、結果を返却するメソッドを持つProtocolです。

/// Validation結果
///
/// - valid: 有効
/// - invalid: 無効
enum ValidationResult {
    case valid
    case invalid(ValidationError)
}

/// Validation結果のエラーに使用
/// LocalizedErrorを使用することで .localizedDescriptionでエラーメッセージを表示可能
protocol ValidationErrorProtocol: LocalizedError { }

/// 文字列のValidationに関する責務
protocol Validator {
    func validate(_ value: String) -> ValidationResult
}

Validatorを複数持つCompositeValidator Protocolを作成します。 Composite ValidatorはvalidatorsとしてValidatorの配列を持ち、validateメソッド実行時に それぞれのValidatorのvalidateメソッドを順次実行、その返却値を元にcomposite validatorの validate実行結果を返却します。 UITextFieldのtextに対して適応するValidatorは基本的にComposite Validatorを使用します。

/// 文字列のValidationに関する責務。複数のValidatorを保持する
/// validatorsに格納されている順にValidationをかけていき、
/// 該当したエラーを返却する。
protocol CompositeValidator: Validator {
    var validators: [Validator] { get }
    func validate(_ value: String) -> ValidationResult
}

 extension CompositeValidator {
    
    func validate(_ value: String) -> [ValidationResult] {
        return validators.map { $0.validate(value) }
    }

     func validate(_ value: String) -> ValidationResult {
        let results: [ValidationResult] = validate(value)
        
        let errors = results.filter { result -> Bool in
            switch result {
            case .valid: return false
            case .invalid: return true
            }
        }
        return errors.first ?? .valid
    }
    
}

Validatorの実装の具体例

上記で記載したValidatorプロトコル、 Composite Validatorプロトコルを元に実際の実装をみていきます。 今回は試しに名前の入力値に対するValidatorを検討します。以下を仕様とします。

  • 文字入力必須
  • 1文字以上、8文字以下
  • 英語大文字小文字のみ入力可

それぞれのケースのエラーをまずは作成します。

enum ValidationError: ValidationErrorProtocol {

    case empty
    case length(min: Int, max: Int)
    case nameFormat
    
    var errorDescription: String? {
        switch self {
        case .empty: return "文字を入力してください"
        case .length(let min, let max): return "\(min)文字以上\(max)文字以下で入力してください。"
        case .nameFormat: return "英語大文字小文字のみで入力してください。"
        }
    }
}

次にそれぞれのケースのValidatorを作成します。

文字入力必須
struct EmptyValidator: Validator {
    
    func validate(_ value: String) -> ValidationResult {
        if value.isEmpty == true {
            return .invalid(.empty)
        } else {
            return .valid
        }
    }
}
1文字以上、8文字以下
struct LengthValidator: Validator {
    let min: Int
    let max: Int
    
    func validate(_ value: String) -> ValidationResult {
        if value.count >= min && value.count <= max {
            return .valid
        } else {
            return .invalid(.length(min: min, max: max))
        }
    }
}
英語大文字小文字のみ入力可
struct NameFormatValidator: Validator {
    
    let regExpression = "^[a-zA-Z]+$"
    
    func validate(_ value: String) -> ValidationResult {
        let predicate = NSPredicate(format: "SELF MATCHES %@", regExpression)
        let result = predicate.evaluate(with: value)
        
        switch result {
        case true: return .valid
        case false: return .invalid(.nameFormat)
        }
    }
}

最後に今まで作成したValidatorを組み合わせたCompositeValidatorを作成します。 これで名前のTextFieldの入力値を検査するValidatorが作成できました。

struct NameValidator: CompositeValidator {
    var validators: [Validator] = [
        EmptyValidator(),
        LengthValidator(min: 1, max: 8),
        NameFormatValidator()
    ]
}

実際の使い方を見てみます。以下のようになります。 Validationの具体的な実装はNameValidator内部に閉じ込められ、同様の処理が拡散することを防いでいます。 Validationの内容を変えたい場合はNameValidatorを変更するか、より下層のここのValidatorを変更すれば良いため修正漏れも防げます。 また、今後新しいComposite Validatorを作成したい場合は今まで作成したValidatorを有効に利用することもできます。 if else などが連続してかかれることもないためコードの冗長性もずいぶん改善され可読性が上がりました。

extension ViewController: UITextFieldDelegate {
    
    func textFieldDidEndEditing(_ textField: UITextField) {
        if textField === textField {
            let nameValidator = NameValidator()
            let result: ValidationResult = nameValidator.validate(textField.text ?? "")
            
            switch result {
            case .valid: break
            case .invalid(let error): print(error.localizedDescription)
            }
        }
    }
}

UITextFieldのExtensionとして定義する

先ほどまでのやり方でかなり改善されましたが、さらに簡略化する方法を考えてみます。

一つの方法としてUITextFieldのExtensionメソッドとしてvalidateを作成する方法があります。 Validateの種別(名前、メールアドレス、パスワード)を定義して、その種別に応じたvalidateを実行するメソッドを作成することで実行時の実装はかなり簡略化できます。

extension UITextField {
    
    // Validateの種類を定義する。
    // name, email, passwordなど。
    // パスワードの確認など比較対象がある場合は、
    //case confirmEmail(origin: String)のように比較対象を入れられるようにすると良い
    // validatorにも比較対象を入れられるようにすることで、そのようなValidationも実装可能
    enum ValidateType {
        case name
    }
    
    // UITextFieldのValidationメソッド
    // Validationの結果をUI上に反映させたい場合は反映先のUILabelを引数として渡すとスッキリする(この実装が綺麗かどうかは要検討)
    @discardableResult
    func validate(type: ValidateType, errorMessageOn label: UILabel? = nil) -> ValidationResult {
        
        let result: ValidationResult
        
        // typeごとに使用するValidatorの初期化やvalidateの実行を行う
        switch type {
        case .name:
            result = NameValidator().validate(text ?? "")
        }
        
        // typeによらずErrorLabelへの反映は共通処理のためここで反映させる
        switch result {
        case .valid: label?.text = nil
        case .invalid(let error): label?.text = error.localizedDescription
        }
        
        // Validate結果を返す
        return result
    }
}

上記のExtensionメソッドを使用することで、使用側の実装は下記のようにかなりシンプルになりました。

extension ViewController: UITextFieldDelegate {
    func textFieldDidEndEditing(_ textField: UITextField) {
        if textField === textField {
            textField.validate(type: .name, errorMessageOn: errorLabel)
        }
    }
}

まとめ

ユーザーの文字入力をアプリ側でValidateする際に、それらの仕組みをCompositeパターンを使用して構造的に作成する方法を記載しました。本記事の方法を使用することで、修正漏れが少なく可読性が高い保守しやすいValidationの仕組みを作成することができるかと思います。

参考