20201204のiOSに関する記事は6件です。

SwiftでCodableの実装を爆速でする方法

はじめに

  • サーバとの通信処理ではResponseの値をmappingやparseして、Swiftで扱いやすいように変換する処理をよく書きます。JSONからSwiftの値に変換するときにはCodableを使うと楽に実装できます。そんなCodableの実装もAPIが増えるごとにたくさん実装、メンテする必要がでてきます。そんな実装を爆速でするための方法について、まとめました。

JSONの型とSwiftで使う型の変換

{ "name": "Tanaka", "age": "26", "gender": "0" }
  • このようなJSONあるときに実装で扱いやすい型に変換するときに苦労した経験があります。(String <-> Intの変換など) Codableで逐次変換処理を書くと可読性が落ちて、メンテし辛いコードが出来上がります。それを克服するためにDTOを使って、可読性の高いコードにしてみます。

DTOを使ったCodableの処理

DTOとは

  • Data Transfer Objectを省略したものです。
  • Layer間の値の受け渡しに使います。

  • このJSONをSWiftの値に変換する処理を書いてみます。

{ "name": "Tanaka", "age": "26", "gender": "0" }
  • before
    • 普通に書くとtry の処理と型変換の処理がたくさん書くことになります
import UIKit

let json = """
{ "name": "Tanaka", "age": "26", "gender": "0" }
""".data(using: .utf8)!

enum Gender: Int {
    case male = 0
    case female
    case unknown
}

struct Person {
    let name: String
    let age: Int
    let gender: Gender

    enum CodingKeys: String, CodingKey {
        case name
        case age
        case gender
    }
}
enum DecodableError: Error {
    case ageError
    case genderError
}

extension Person: Decodable {
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
        let ageValue = try container.decode(String.self, forKey: .age)
        guard let age = Int(ageValue) else {
            throw DecodableError.ageError
        }
        self.age = age

        let genderString = try container.decode(String.self, forKey: .gender)
        guard let genderValue = Int(genderString), let gender = Gender(rawValue: genderValue) else {
            throw DecodableError.genderError
        }
        self.gender = gender
    }
}

let decoder = JSONDecoder()
let person = try decoder.decode(Person.self, from: json)
  • after
    • DTODecodableを使って、tryの処理を書かなくていいようにします。DTOではJSONで宣言されている型の通りに変換することで値のparseのみ行います。Entity側ではDTOの値を元にEntityで必要な型に変換します。Entity側ではtryの処理を意識しないのでコードの可読性が上がりました。
import UIKit

protocol DTODecodable: Decodable {
    associatedtype DTO: Decodable
    init(dto: DTO) throws
}

extension DTODecodable {
    init(from decoder: Decoder) throws {
        let dto = try DTO(from: decoder)
        self = try Self.init(dto: dto)
    }
}

let json = """
{ "name": "Tanaka", "age": "26", "gender": "0" }
""".data(using: .utf8)!

enum Gender: Int {
    case male = 0
    case female
    case unknown
}

struct Person {
    let name: String
    let age: Int
    let gender: Gender

    enum CodingKeys: String, CodingKey {
        case name
        case age
        case gender
    }
}

extension Person: DTODecodable {
    struct DTO: Decodable {
        let name: String
        let age: String
        let gender: String
    }

    init(dto: DTO) throws {
        self.name = dto.name
        guard let age = Int(dto.age) else {
            throw DTODecodableError.ageError
        }

        guard let genderValue = Int(dto.gender),
              let gender = Gender(rawValue: genderValue) else {
            throw DTODecodableError.genderError
        }

        self.age = age
        self.gender = gender
    }

    enum DTODecodableError: Error {
        case ageError
        case genderError
    }
}

let decoder = JSONDecoder()
let person = try decoder.decode(Person.self, from: json)
  • 実装イメージ

Untitled Diagram.png

Codableのコードを生成する

  • ここでは2つのツールについて紹介します。2つともJSONを元にCodableの実装を生成するツールです。

  • quicktype

    • WebでJSONからCodableの実装が生成されます
  • YutoMizutani/JSONtoCodable

    • JSONtoCodableを使うとCodableの実装が生成されます

Kapture 2020-12-04 at 18.21.51.gif

まとめ

  • DTOを使うことでCodableの可読性があがります。
  • 生成ツールを使うとさらに爆速で開発できます

参考リンク

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

iOSでAirtestを動かしてみる

はじめに

AirtestIDE でスマホアプリ(ゲーム)のテスト自動化をしています。そこで実際に作成したスクリプトや環境をつくるために実施したことを綴ってみたいと思います。

参考

こちらのドキュメンを参考にして AirtestIDEから iOSに接続、テストスクリプトの実行までを行う環境を構築しました。
Airtest Project Docs - 2.4 iOS device connection / 2.4.3 Deployment process -
https://airtest.doc.io.netease.com/en/IDEdocs/device_connection/4_ios_connection/
※AirtestIDEの右下にある「?」アイコンから飛べます

環境

macOS 10.15.7
Xcode 11.6
Airtest IDE 1.2.6(Python)
iPhone XS(iOS 12.0)

セットアップ

Step1 iOS-Tagent

iOS-Tagent をクローンします。

$ git clone https://github.com/AirtestProject/iOS-Tagent.git

Xcodeで開きます。
スクリーンショット 2020-12-03 17.23.08.png
WebDriverAgentを選択します。ここでPCに実機を接続してから、端末の選択を開きます。(ここでは「iPhone SE (2nd generatio)」となっている箇所)
スクリーンショット 2020-12-03 17.33.27.png
PCに接続した実機を、選択します。
スクリーンショット 2020-12-03 17.34.01.png
とりあえず、動かしてみます。

ワーニングを順次解消していきます。
まず、ターゲットビルドを実行する端末のiOSバージョンにあわせます。
スクリーンショット 2020-12-03 17.40.15.png
スクリーンショット 2020-12-03 17.40.40.png
スクリーンショット 2020-12-03 17.41.02.png
WebDriverAgentRunner にて、Teamを選択します。(無い場合は AppleIDから作成)
スクリーンショット 2020-12-03 17.50.43.png

Product Bundle Identiferを設定します。(ユニークな文字列にする)
スクリーンショット 2020-12-03 17.54.46.png

再度、ビルドします。

WebDriverAgentRunner がビルドされます。ここで、「設定」アイコンをタップします。

設定から「一般」をタップします。

「プロファイルとデバイス管理」をタップします。

WebDriverAgentRunner のデベロッパを信頼します。

再度実行します。
スクリーンショット 2020-12-03 18.22.24.png

Step2 Set Ploxy

iproxyをインストールします。

$ brew install libimobiledevice

iproxyを実行します。

$ iproxy 8100 8100
Creating listening port 8100 for device port 8100
waiting for connection

Step3 AirtestIDE

接続情報を入力して「Connect」ボタンをクリックします。

iOS端末に接続されます。これでAirtestのスクリプトを実行することができるようになりました。
スクリーンショット 2020-12-04 13.18.30.png

はまりポイント

いくつかはまりポイントがあったので共有。

Xcode12だと iOS-Tagent が動かない

最新の Xcode12 だと動かない。Xcode11にて解決。
おなじような情報がありました。
https://github.com/AirtestProject/iOS-Tagent/issues/182

iOS端末側のアプリが起動しない

信頼しないといけない。

AirtestIDEが 横向きのアプリに対応していない

いまのところ対処法がなさそうでして、AirtestIDEの改修に期待したいとおもいます。

おわりに

参考になった箇所などありましたでしょうか。実際にiOS端末でAirtestIDEを試そうとした際に、いくつか試行錯誤したことがありましたので、何かのお役に立てたなら幸いです。

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

[事例報告] Instance method ‘didTapMessage(in:)’ nearly matches defaulted requirement ‘didTapMessage(in:)’ of protocol ‘MessageCellDelegate’

本事例はMessageKitで起きた問題ですが、一般に起こりうるのでメモしておきます。

下記のようなプロトコルがあり

public protocol MessageCellDelegate: MessageLabelDelegate {
 func didTapMessage(in cell: MessageCollectionViewCell)
}

public extension MessageCellDelegate {
  func didTapBackground(in cell: MessageCollectionViewCell) {}
}

これを実装しようとすると

Instance method didTapMessage(in:) nearly matches defaulted requirement didTapMessage(in:) of protocol MessageCellDelegate

と怒られます。

原因がはMessageKitで用意されているMessageCollectionViewCellと同じ名前のMessageCollectionViewCellというクラスを
別にアプリ内で定義していたからでした。

気づきにくいバグでした。ご注意を‥。

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

アプリバックグラウンド時にiBeaconレンジングを継続的に行う方法【iOS14.2】

iBeaconの情報取得方式

  • iOSのCLLocationManagerによるiBeaconの情報取得方法にはリージョン監視レンジングの2つの方式がある。両者を比較すると、後述のようにレンジングでしか実現できないユースケースがある
  • リージョン監視はアプリがバックグラウンドでも動作するのに対し、レンジングはフォアグラウンドでのみ動作するとしている記事がよくみられる。しかし少なくとも後述の環境ではバックグラウンドでのレンジングが成功した。
  • 本稿ではユーザによる画面オフやホーム画面への遷移によってアプリがバックグラウンド状態になった後も継続してiBeaconのレンジングを行う方法を記載する。

リージョン監視

  • IDで指定したビーコンの電波をiOS端末が検知できる範囲を「リージョン」と定義し、ユーザによるリージョンへの出入りやある時点でユーザがリージョン内外のどちらにいるのかを通知する。
  • リージョンの内か外かでしか判定ができないため、ユーザとビーコンとの距離の変化をモニタリングできない
  • リージョン監視はアプリがフォアグラウンドでない場合も継続的に動作する(iOSでBeaconの振る舞いを確認するを参考にさせていただきました)。

レンジング

  • IDで指定したビーコンの電波強度等の情報を1秒ごとに取得する。
  • レンジングを活用することで、ユーザとビーコンとのおよその距離に応じて処理を変えるなど、柔軟なアプリ設計が可能になる。
  • 複数のビーコンを設置し、継続的に電波強度をモニタリングできれば、屋内移動ログの取得といったユースケースに応用できる。
  • アプリバックグラウンド時の継続的なレンジングは不可能とする情報が多いが、後述の方法で後述の方法で動作確認が取れた

環境

  • iPhone11 Pro(実機)
  • iOS14.2
  • Xcode Version12.2

バックグラウンドレンジングの実装

準備

実装のポイント

リージョン監視及びレンジングの通常の実装に対し、下記3点の処理を追加する。
- CLLocationManagerallowsBackgroundLocationUpdatesをtrueに設定し、バックグラウンドでのロケーション更新を許可する(①)。
- 同じくCLLocationManagerpausesLocationUpdatesAutomaticallyをfalseに設定し、iOSによるロケーション更新の自動中断をオフにする(②)。
- レンジングを開始する前にstartUpdatingLocation()ロケーションの更新を開始する(③)。

ソースコード

  • 上記のポイント以外は通常のリージョン監視+レンジングと同様である。CLLocationManagerのデリゲートメソッドの動きに関してはiBeacon についてをご参照ください。
ViewController.swift
import UIKit
import CoreLocation

class ViewController: UIViewController, CLLocationManagerDelegate {

    var myLocationManager:CLLocationManager!
    var myBeaconRegion:CLBeaconRegion!
    let UUIDList = [
        "12345678-1234-1234-1234-123456789012"
    ]

    override func viewDidLoad() {
        super.viewDidLoad()
        myLocationManager = CLLocationManager()
        myLocationManager.delegate = self
        myLocationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
        // バックグラウンドでのロケーション更新を許可しておく(①)
        myLocationManager.allowsBackgroundLocationUpdates = true
        // ロケーション更新の自動中断をオフにしておく(②)
        myLocationManager.pausesLocationUpdatesAutomatically = false

        let status = CLLocationManager.authorizationStatus()
        print("CLAuthorizedStatus: \(status.rawValue)");
        if(status == .notDetermined) {
            myLocationManager.requestAlwaysAuthorization()
        }
        // レンジングを始める前にロケーション更新を開始しておく(③)
        myLocationManager.startUpdatingLocation()
    }

    // リージョン監視を開始
    private func startMyMonitoring() {
        for i in 0 ..< UUIDList.count {
            let uuid: NSUUID! = NSUUID(uuidString: "\(UUIDList[i].lowercased())")
            let identifierStr: String = "Beacon:No.\(i)"
            myBeaconRegion = CLBeaconRegion(uuid: uuid as UUID, identifier: identifierStr)
            myBeaconRegion.notifyEntryStateOnDisplay = false
            myBeaconRegion.notifyOnEntry = true
            myBeaconRegion.notifyOnExit = true
            myLocationManager.startMonitoring(for: myBeaconRegion)
        }
    }

    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        print("didChangeAuthorizationStatus");
        switch (status) {
        case .notDetermined:
            print("not determined")
            break
        case .restricted:
            print("restricted")
            break
        case .denied:
            print("denied")
            break
        case .authorizedAlways:
            print("authorizedAlways")
            startMyMonitoring()
            break
        case .authorizedWhenInUse:
            print("authorizedWhenInUse")
            startMyMonitoring()
            break
        @unknown default:
            print("")
        }
    }

    func locationManager(_ manager: CLLocationManager, didStartMonitoringFor region: CLRegion) {
        manager.requestState(for: region);
    }

    func locationManager(_ manager: CLLocationManager, didDetermineState state: CLRegionState, for region: CLRegion) {
        switch (state) {
        case .inside:
            print("iBeacon inside");
            manager.startRangingBeacons(in: region as! CLBeaconRegion)
            break;
        case .outside:
            print("iBeacon outside")
            manager.startRangingBeacons(in: region as! CLBeaconRegion)
            break;
        case .unknown:
            print("iBeacon unknown")
            break;
        }
    }

    // レンジング(ビーコンの電波強度等の情報を毎秒取得)
    func locationManager(_ manager: CLLocationManager, didRangeBeacons beacons: [CLBeacon], in region: CLBeaconRegion) {
        if beacons.count > 0 {
            for beacon in beacons {
                print(beacon)
            }
        }
    }

    func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
        print("didEnterRegion: iBeacon found");
        manager.startRangingBeacons(in: region as! CLBeaconRegion)
    }

    func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {
        print("didExitRegion: iBeacon lost");
        manager.stopRangingBeacons(in: region as! CLBeaconRegion)
    }
}

CLLocationManagerのバックグラウンド処理仕様

  • 前述の準備と実装のポイントに従い、バックグラウンドでのロケーション更新を開始することで、バックグラウンド時もレンジングが継続できるようになる
  • ビーコン情報取得とロケーション取得はいずれもCLLocationManagerが担っているため、両者のバックグラウンド実行可否が一致する仕様になっていると考えられるが、公式ドキュメントの記載は見つけられなかった。

アプリ実行時のXcodeコンソールログ

今回は下記の順でアプリの状態を変化させた。

  • アプリ起動(フォアグラウンドへ移行)→画面オフでバックグラウンドへ移行→画面オンでフォアグラウンドへ移行→ホーム画面へ遷移させて再びバックグラウンドへ移行

アプリのライフサイクルを分かりやすくするためにAppDelegate内でライフサイクルイベントをprintしている。
(参考:NotificationCenterを用いたライフサイクルイベントの検知

  • didFinishLaunch:アプリ起動
  • willEnterForeground:フォアグラウンドへの移行
  • didEnterBackground:バックグラウンドへの移行
Xcodeコンソールログ
didFinishLaunch
CLAuthorizedStatus: 3
willEnterForeground
didChangeAuthorizationStatus
authorizedAlways
iBeacon inside
CLBeacon (uuid:12345678-1234-1234-1234-123456789012, major:0, minor:0, proximity:1 +/- 0.36m, rssi:-55, timestamp:2020-12-03 19:22:15 +0000)
CLBeacon (uuid:12345678-1234-1234-1234-123456789012, major:0, minor:0, proximity:2 +/- 0.56m, rssi:-55, timestamp:2020-12-03 19:22:16 +0000)
didEnterBackground
CLBeacon (uuid:12345678-1234-1234-1234-123456789012, major:0, minor:0, proximity:2 +/- 0.81m, rssi:-61, timestamp:2020-12-03 19:22:17 +0000)
CLBeacon (uuid:12345678-1234-1234-1234-123456789012, major:0, minor:0, proximity:2 +/- 1.04m, rssi:-63, timestamp:2020-12-03 19:22:18 +0000)
CLBeacon (uuid:12345678-1234-1234-1234-123456789012, major:0, minor:0, proximity:2 +/- 0.76m, rssi:-54, timestamp:2020-12-03 19:22:19 +0000)
CLBeacon (uuid:12345678-1234-1234-1234-123456789012, major:0, minor:0, proximity:2 +/- 0.84m, rssi:-59, timestamp:2020-12-03 19:22:20 +0000)
willEnterForeground
CLBeacon (uuid:12345678-1234-1234-1234-123456789012, major:0, minor:0, proximity:2 +/- 0.56m, rssi:-51, timestamp:2020-12-03 19:22:21 +0000)
CLBeacon (uuid:12345678-1234-1234-1234-123456789012, major:0, minor:0, proximity:2 +/- 0.43m, rssi:-50, timestamp:2020-12-03 19:22:22 +0000)
CLBeacon (uuid:12345678-1234-1234-1234-123456789012, major:0, minor:0, proximity:2 +/- 0.32m, rssi:-48, timestamp:2020-12-03 19:22:23 +0000)
CLBeacon (uuid:12345678-1234-1234-1234-123456789012, major:0, minor:0, proximity:1 +/- 0.28m, rssi:-48, timestamp:2020-12-03 19:22:24 +0000)
CLBeacon (uuid:12345678-1234-1234-1234-123456789012, major:0, minor:0, proximity:1 +/- 0.28m, rssi:-49, timestamp:2020-12-03 19:22:25 +0000)
CLBeacon (uuid:12345678-1234-1234-1234-123456789012, major:0, minor:0, proximity:2 +/- 0.28m, rssi:-49, timestamp:2020-12-03 19:22:26 +0000)
didEnterBackground
CLBeacon (uuid:12345678-1234-1234-1234-123456789012, major:0, minor:0, proximity:2 +/- 0.28m, rssi:-49, timestamp:2020-12-03 19:22:27 +0000)
CLBeacon (uuid:12345678-1234-1234-1234-123456789012, major:0, minor:0, proximity:1 +/- 0.26m, rssi:-48, timestamp:2020-12-03 19:22:28 +0000)
CLBeacon (uuid:12345678-1234-1234-1234-123456789012, major:0, minor:0, proximity:1 +/- 0.25m, rssi:-48, timestamp:2020-12-03 19:22:29 +0000)
CLBeacon (uuid:12345678-1234-1234-1234-123456789012, major:0, minor:0, proximity:1 +/- 0.25m, rssi:-48, timestamp:2020-12-03 19:22:30 +0000)
CLBeacon (uuid:12345678-1234-1234-1234-123456789012, major:0, minor:0, proximity:1 +/- 0.25m, rssi:-48, timestamp:2020-12-03 19:22:31 +0000)
CLBeacon (uuid:12345678-1234-1234-1234-123456789012, major:0, minor:0, proximity:1 +/- 0.25m, rssi:-48, timestamp:2020-12-03 19:22:32 +0000)
CLBeacon (uuid:12345678-1234-1234-1234-123456789012, major:0, minor:0, proximity:1 +/- 0.25m, rssi:-48, timestamp:2020-12-03 19:22:33 +0000)

バックグラウンド時も問題なくビーコン電波強度等を取得できている。
なお、今回は単一のビーコンでの20秒程度のレンジングログを示したが、3個のビーコンを対象とした数分単位のバックグラウンドレンジングも問題なく動作した。

Appleのポリシーに適合する実装であるか

  • Appleがバックグラウンドでのビーコンレンジングをどの程度許容する姿勢であるかは不明。直近でもiOS13でアプリのバックグラウンド実行が強制終了される事象が報告され、その後のアップデートで修正されていた(iOS13.2.2公開、データ通信不具合、バックグラウンドアプリ強制終了など修正)。今後のOSアップデートで仕様が変わる可能性がある。
  • WWDC19での発表を参照すると、iOSでのバックグラウンド処理は必要性の高い特定のユースケースに限定して用いられるべきというAppleの意図が読み取れる。また、バックグラウンド処理の実装に当たってはパフォーマンス・バッテリー消費・プライバシーの3点に留意すべきことも強調されている。バックグラウンドでのレンジングがこうしたAppleの意図にどこまで適合的かは不明確であるため、ストア申請時は注意が必要である
  • App Store Reviewガイドラインでは下記の記述がみられる。

バックグラウンドでの位置情報取得モードを使用する場合は、それによってバッテリー持続時間が大幅に減少する可能性があることを通知してください

補足

  • 位置情報の利用を「常に許可」ではなく「Appの使用中のみ許可」としていた場合、バックグラウンド移行後のレンジングが約10秒で停止した。しかし、10秒より長い時間動作する現象もみられた。条件は不明。
  • ロケーション更新時のデリゲートメソッドlocationManager(_:didUpdateLocations:)を実装する必要はない。
  • 今回は省略したが、必要性が無くなった時点でバックグラウンドでのレンジングを停止し、ユーザに見えにくい部分でのCPU負荷やバッテリー消費を避けるべきである。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【超ミニマム】AWS AppSync + AmplifyでiOSチャットアプリを作る

はじめに

この記事はand factory Advent Calendar 2020 の4日目の記事です。
昨日は@ykkdさんのSwiftlint autocorrectでコードを自動修正するでした!?

AWS AppSync / Amplify

チャットアプリをはじめとしたモバイルのバックエンドとしてFirebaseが利用されることが多いと思いますが、
AWSのマネージドサービスには、他のAWSサービスとの連携が容易という大きなメリットがあります。
私自身iOSエンジニアではあるもののAWSの提供しているサービスや構成には興味があったので、
今回はAppSyncとAmplifyを使ってみることにしました。

AppSyncとはAWSが提供しているGraphQLでのサービス開発をサポートするマネージドサービスで、
GraphQLで用意されているサブスクリプション機能を使うことでチャット機能を比較的用意に作ることができます。
また、AmplifyはクライアントがAppSyncにアクセスするために提供されているツールで、
iOSではAmplify SDKを利用することで、AppSyncとのデータの受け渡しをSDK側が請け負ってくれます。

こんなイメージだと思っています↓
スクリーンショット 2020-12-04 1.26.02.png

今回はAWS AppSync + Amplifyで爆速で(?)超ミニマムなチャットアプリを作ってみます。

Amplifyのセットアップ

前提条件

  • AWSアカウントを持っている
  • Amplifyのセットアップが完了している
    • Amplifyを実行するコマンドラインツールのセットアップ、Podのインストールを行います。
    • 詳細はこちらを参照ください。

Amplifyのコマンドラインツールで以下を実行します。

Amplifyの初期化

amplify init

その後プロジェクト名などの初設定を尋ねられるので回答します。今回は以下のように答えました。

? Enter a name for the project 
    -> SampleChatApp
? Enter a name for the environment
    -> dev
? Choose your default editor:
    -> Visual Studio Code
? Choose the type of app that you're building
    -> ios
? Do you want to use an AWS profile?
    -> Yes
? Please choose the profile you want to use
    -> default

✅ Amplify setup completed successfully.
と出たら完了です。

APIのセットアップ

amplifyのセットアップが終わったら、コマンドラインツールを使ってAPIをセットアップしていきます。

プロジェクトルートで下記のコマンドを実行し、確認内容に沿って回答していきます。

amplify add api

再び質問されます。次はAPIについての初設定です。今回のケースの回答は以下です。

? Please select from one of the below mentioned services: 
    -> GraphQL
? Provide API name:
    -> samplechatapp
? Choose the default authorization type for the API
    -> API key
? Enter a description for the API key:
    -> SampleChatApp's API key.
? After how many days from now the API key should expire (1-365):
    -> 7
? Do you want to configure advanced settings for the GraphQL API
    -> No, I am done.
? Do you have an annotated GraphQL schema?
    -> No
? Choose a schema template:
    -> Single object with fields (e.g., “Todo” with ID, name, description)


GraphQL schema compiled successfully.

? Do you want to edit the schema now?
    -> Yes // Yesとするとエディタが開き、スキーマを編集できる

モデル定義

APIの作成の最後の質問にYesで答えるとエディタが開きスキーマを編集できるようになります。
GraphQLではgraphqlファイルで定義されたスキーマをもとにAPIを作成します。
ここでは簡単にメッセージのモデルを以下のように定義しました。
テキスト、作成日(エポックマイクロ秒を想定)、UserIDの最小限のプロパティです。

schema.graphql
type Message @model {
  id: ID!
  text: String!
  createdAt: String!
  user: String!
}

スキーマを定義したら、
これまでに作成したスキーマやらAPIの定義ファイルやらのローカルのリソースをリモートにpushします。

amplify push

pushに成功すると、例によって質問がなされます。
この回答に基づいて作成したAPIにアクセスするためのラップ処理の種類や命名などが決まります。

? Do you want to generate code for your newly created GraphQL API
    -> Yes
? Enter the file name pattern of graphql queries, mutations and subscriptions
    -> graphql/**/*.graphql
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscripti
ons
    -> Yes
? Enter maximum statement depth [increase from default if your schema is deeply nested]
    -> 2
? Enter the file name for the generated code
    -> API.swift

ここまで行うとAmplifyのコマンドラインツールにてAPIが作成され、
AWSのコンソール->サービス→AppSyncから確認することができます。

スクリーンショット 2020-12-03 22.09.29.png

クライアント実装

セットアップ

amplify initの際に作成された、
amplifyconfiguration.jsonawsconfiguration.jsonのふたつのjsonファイルを
Xcodeのプロジェクト内に移し替えます。

また、アプリ起動時のAmplifyのセットアップ処理としてAppDelegateで以下を実行します。

AppDelegate.swift
import UIKit
import Amplify
import AmplifyPlugins

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        // Setup Amplify
        do {
            try Amplify.add(plugin: AWSAPIPlugin(modelRegistration: AmplifyModels()))
            try Amplify.configure()
        } catch {
            print("An error occurred setting up Amplify: \(error)")
        }

        // 略

        return true
    }
}

チャット機能実装

チャット機能の実装です。

viewDidLoadでデータソースに保存されたメッセージを取得する

REST APIでのGetはGraphQLではqueryが請け負います。
Amplify SDKではAmplify.API.query(request:)を実行すして、Messageの配列を取得します。
また、件数を指定するlimitや次の値の参照を持つnextTokenを組み合わせることでページネーションを行うこともできます。

ChatViewController.swift
    @IBOutlet private weak var tableView: UITableView!

    var messages: [Message] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        self.fetchMessage()
    }

    func fetchMessage() {
        // Amplify SDK経由でqueryオペレーションを実行しMessageの配列を取得
        Amplify.API.query(request: .list(Message.self, where: nil)) { event in
            switch event {
            case .success(let result):
                // GraphQLの場合、Query失敗時のerrorもレスポンスに含まれる
                switch result {
                case .success(let messages):
                    self.messages = messages
                    DispatchQueue.main.async {
                        // tableViewを更新
                        self.tableView.reloadData()
                    }
                case .failure(let error):
                    // サーバーから返されるエラーはこっち
                }
            case .failure(let error):
                // 通信エラー等の場合はこっち
            }
        }
    }

送信ボタンでデータを投稿する

GraphQLにおいてデータの作成、変更などの書き込み操作はmutateが行います。

ChatViewController.swift
    @IBAction func tappedSendButton() {
        // キーボード閉じる
        self.textField.resignFirstResponder()

        // メッセージ内容
        guard let text = self.textField.text, !text.isEmpty else {
            return
        }
        // 送信時間を取得
        let createdAt = String(Date().timeIntervalSince1970)
        // 別管理しているUserID
        let user = UserIdRepositoryProvider.provide().getUserId()
        let message = Message(text: text, ts: ts, user: user!)

        // mutateで新規メッセージを作成
        Amplify.API.mutate(request: .create(message)) { event in
            switch event {
            case .success(let result):
                switch result {
                case .success(let message):
                    print("Successfully created the message: \(message)")
                case .failure(let graphQLError):
                    // サーバーからのエラーの場合はこっち
                    print("Failed to create graphql \(graphQLError)")
                }
            case .failure(let apiError):
                // 通信まわりなどのErrorになった場合はこっち
                print("Failed to create a message", apiError)
            }
        }

        // 初期化しておく
        self.textField.text = ""
    }

データソースの購読

最後にリアルタイムな結果の反映について実装します。
GraphQLではサブスクリプション機能を使うことによって、双方向のソケット通信を実現します。
Amplifyでは、Amplify.API.subscribe()を実行することで、データソースの変更をレスポンシブに反映できるようになります。

ChatViewController.swift
    override func viewDidLoad() {
        super.viewDidLoad()
        self.fetchMessage()
        self.subscribeMessage()
    }

    func subscribeMessage() {
        // 新たなメッセージの作成を購読する
        subscription = Amplify.API.subscribe(request: .subscription(of: Message.self, type: .onCreate), valueListener: { (subscriptionEvent) in
            // 購読したイベント内容をチェック
            switch subscriptionEvent {

            // サブスクリプションの接続状態の変更を検知
            case .connection(let subscriptionConnectionState):
                print("Subscription connect state is \(subscriptionConnectionState)")

            // データの更新を検知
            case .data(let result):
                switch result {
                case .success(let createdMessage):
                    self.messages.append(createdMessage)
                    DispatchQueue.main.async {
                        // テーブル更新
                        self.tableView.reloadData()

                        // 最新のメッセージまでスクロール
                        let indexPath = IndexPath(row: self.messages.count - 1, section: 0)
                        self.tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
                    }
                case .failure(let error):
                    print("Got failed result with \(error.errorDescription)")
                }
            }
        }) { result in
            switch result {
            case .success:
                print("Subscription has been closed successfully")
            case .failure(let apiError):
                print("Subscription has terminated with \(apiError)")
            }
        }
    }

ひとまず完成

できてるっぽい〜〜〜

やりきれなかったこと

できてるっぽい雰囲気が若干しますが、実は今回ソートができませんでした。
(サブスクリプションで購読したものは時系列順で取得できるのでごまかせるのですが、queryで取得したものはKeyのMessageのIDでソートされてしまい順序性が狂ってしまいます)

時系列順 ID順(再度queryで取得した場合)
Simulator Screen Shot - iPhone 11 - 2020-12-03 at 23.53.48.png Simulator Screen Shot - iPhone 11 - 2020-12-03 at 23.54.10.png

チャットであれば当然時系列順に並んでほしいところですが、超ミニマムということでお許しください。
(schema.graphqlをいじってソートキーにcreatedAtを指定することはできるのですが、
Amplify SDKで経由でどのように呼び出せばいいのかわからず。。知見がある人がいたら教えていただきたいです。)

おわりに

本記事では全く触れられませんでしたが、チャット機能に関しては

  • 認証情報との紐付け
  • 送信開始時点で送信中というステータスがユーザーに伝わるようにする
  • 送信に失敗したときにユーザーに通知して再送信を促す
  • 送信中にアプリを落としても送信が行われるようにする
  • 画像や動画などコンテンツの拡充
  • データの永続化をしてユーザービリティを高める

など考え出すとどんどん検討項目が出てくるので、
SDKにどこまでおまかせするべきなのか難しいところだなと思いました。
とはいえAmplify iOS SDKに関してはまだまだ調査段階なので、
また色々触って理解度深めていきたいところです。

最後まで見ていただきありがとうございました?‍♂️

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

WKWebViewにもUIWebViewと同じようにSession Cookieを使いたい

はじめに

株式会社ピー・アール・オーのアドベントカレンダー4日目です。

今まで使えたUIWebView、いよいよ2020年12月で利用できなくなります。つい最近までUIWebViewからWKWebViewへの移行対応をやりましたが、WebAPIと一緒に返ってきたSession Cookieを使ってWebページを表示していて、WKWebViewに移行するとNSHttpCookieStorageからCookieを自動的に取れなくて、その対応についての調査は結構時間かかりました。

そこで、WKWebViewに移行するとき、Session Cookieをセットする方法の記事があったらいいなと思って、この記事を書きました。

WKProcessPoolを用意する

さて、一番最初に用意しないといけないのは、WKProcessPoolのインスタンスです。これを利用して、WKWebViewを作成時に使われる設定値に設定します。複数WebViewで同じドメインのWebページを表示したい時に、全てのWebViewが同じWKProcessPoolのインスタンスを参照しないといけないです。それぞれのWebViewが独自のWKProcessPoolを持つと、WebViewにCookieをセットするとき、コンフリクトが発生する可能性があって、Cookieをセット失敗になるので、WKProcessPoolインスタンスを作るとき、常にアプリの同じ所を参照するようにします。

/// WKWebViewUtil.m
static WKProcessPool *processPool = nil;
+ (WKProcessPool *)sharedProcessPool {
    static dispatch_once_t once;
    dispatch_once(&once, ^{
        processPool = [[WKProcessPool alloc] init];
    });
    return processPool;
}

WKWebViewを初期化する

次はWebViewの初期化です。
WebViewが初期化される前に用意したWKProcessPoolのインスタンスをセットしないといけないので、今回のWebView作成はStroyboardではなく、全てコードで生成します。

WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
configuration.processPool = [WKWebViewUtil sharedProcessPool];
WKWebView *webView = [[WKWebView alloc] initWithFrame:frame configuration:configuration];

WKWebViewにCookieを渡す

WKWebViewの初期化が終わったので、次は作られたWebViewにCookieをセットします。
UIWebViewと違い、WKWebViewは自分自身の内部に持っていたWKHTTPCookieStoreの方でCookieを管理していたため、手動でNSHTTPCookieStorageに保存したSession CookieをWKHTTPCookieStoreに渡さないといけません。
WKWebViewにCookieをセットする処理は非同期のため、Webページ表示用に必要なCookieを全てセット完了後Webページを表示する必要があるため、全てのCookieをWKWebViewにセット完了のチェックが必要になります。

NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
for (NSHTTPCookie *cookie in cookieStorage.cookies) {
    // Cookieをセットする
    [webView.configuration.websiteDataStore.httpCookieStore] setCookie:cookie completionHandler:^{
        // Cookieセット処理は非同期になるので、ここで全部のCookieがセット完了チェックが必要
    }];
}

これだけだと、連続的に同じWebViewのインスタンスを作るとき、WebViewにCookieセット完了のハンドラーが呼ばれないことがあって、WebViewにCookieセット完了したかどうかは不明な状態になるので、WKHTTPCookieStore setCookie: completionHandler:を呼んだ後、下記の処理を追加して、強制的にCookieセット完了後ハンドラーが呼ばれるようにする必要があります。

// Cookieセット完了後必ずcompletionHandlerが呼ばれるように追加する処理
[[webView.configuration.websiteDataStore] fetchDataRecordsOfTypes:[NSSet<NSString *> setWithObject:WKWebsiteDataTypeCookies] completionHandler:^(NSArray<WKWebsiteDataRecord *> *records) {}];

初期化したWKWebViewを親のViewに追加する

WebViewが用意されたとで、次は画面上に表示するため、親のViewに追加する必要があります。ViewControllerのviewDidLoadに作成したWebViewを追加します。

[self.view addSubview:webview];

NSURLRequestのヘッダーに同じCookieをセットする

Webページを表示する用のWebViewは準備できましたが、しかし今の状態はまだ画面が正しく表示できません。
画面表示する前に、URLRequestのヘッダーにもWebViewにセットしたCookieをセットしないといけません。

NSMutableURLRequest *urlRequest = [NSMutableURLRequest requestWithURL:url];
// WebViewにセットしたCookieをリクエストヘッダーにもセットする
NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
NSDictionary *headerFields = [NSHTTPCookie requestHeaderFieldsWithCookies:cookieStorage.cookies];
[urlRequest setAllHTTPHeaderFields:headerFields];

// WebViewを表示する
[webView loadRequest:urlRequest];

これで、UIWebViewと同じように、WebAPIと一緒に返ってきたSession Cookieを利用して、WKWebViewも認証必要なWebページ画面が正しく表示できます。

完成版のコード

/// WKWebViewUitl.m
static WKProcessPool *processPool = nil;
+ (WKProcessPool *)sharedProcessPool {
    static dispatch_once_t once;
    dispatch_once(&once, ^{
        processPool = [[WKProcessPool alloc] init];
    });
    return processPool;
}

+ (void)initWebViewWithFrame:(CGRect)frame 
           completionHandler:(void (^)(WKWebView *))handler {
    WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
    configuration.processPool = [WKWebViewUtil sharedProcessPool];
    WKWebView *webView = [[WKWebView alloc] initWithFrame:frame configuration:configuration];

    NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
    if ([cookieStorage.cookies count] <= 0) {
        // Cookieは存在しない場合、そのまま返す
        handler(webView);
    }

    __block long setCookieCount = 0;
    for (NSHTTPCookie *cookie in cookieStorage.cookies) {
        // Cookieをセットする
        [webView.configuration.websiteDataStore.httpCookieStore] setCookie:cookie completionHandler:^{
            setCookieCount += 1;
            if (setCookieCount == [cookieStorage.cookies count]) {
                handle(webView);
            }
        }];
        [[webView.configuration.websiteDataStore] fetchDataRecordsOfTypes:[NSSet<NSString *> setWithObject:WKWebsiteDataTypeCookies] completionHandler:^(NSArray<WKWebsiteDataRecord *> *records) {}];
    }
}
/// ViewController.m
@property (strong, nonatomic) WKWebView *webView;
- (void)viewDidLoad {
    [super viewDidLoad];
    __weak typeof(self) weakSelf = self;
    [WKWebViewUtil initWebViewWithFrame:self.view.bounds completionHandler:^(WKWebView *)webView {
        weakSelf.webView = webView;
        [weakSelf.view addSubview: weakSelf.webView];
        NSMutableURLRequest *urlRequest = [NSMutableURLRequest requestWithURL:url];
        // WebViewにセットしたCookieをリクエストヘッダーにもセットする
        NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
        NSDictionary *headerFields = [NSHTTPCookie requestHeaderFieldsWithCookies:cookieStorage.cookies];
        [urlRequest setAllHTTPHeaderFields:headerFields];

        // WebViewを表示する
        [weakSelf.webView loadRequest:urlRequest];
    }];
}

最後に

  • 今回WKWebViewにSession Cookieを渡して、Webページを正しく表示するために結構大変でした。やっぱり今後はWebAPIで返って来たSession Cookieを使ってWebページを表示するのはやめましょう。
  • たとえまだ使えるだとしても、レガシーな設計やコードは放っておくと、いずれ使えなくなるので、今後技術的な負債に繋がる可能性は大いにあるから、可能なら早めに修正する方向で持っていきましょう。
  • スマホアプリと連携するWebサービス・WebAPIはサーバエンジニアだけでなく、ちゃんとスマホエンジニアを巻き込んで設計しましょう。

参考サイト

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