20190318のiOSに関する記事は12件です。

Swiftでprotocol指向なDIによる特定部分に依存しない自作APIネットワークレイヤー

はじめに

概要

責務の分離とprotocolとDIによる特定の責務に依存しない柔軟なAPIリクエスト用のネットワークレイヤー(APINetworkLayer)を作成した。

特に実際にリクエストを送信する部分をURLSessionやAlamofireなどを簡単にDIで切り替え可能で、jsonのパースも同様に簡単に実装方法を切り替えられることを意識して作成している。

この記事ではこのネットワークレイヤーの解説と使い方を記載する。

APIの作成を簡単かつ柔軟にするため少々複雑な部分もあるが、使用例等を含めて全体を見通せば理解できると思う。

すぐに試したい人はAPINetworkLayerのコードをプロジェクトに取り込み、後半の「プロジェクト側での実装」のコードを使えば簡単に動作させることも可能である。

注意

  • 便宜上structを使った方が適切な場合もclassと表現している
  • エラーハンドリングは今回の本質とはずれるのでしっかり書いていない
  • HTTPHeaderに関する詳細は割愛している

4つの主要なコンポーネント

APINetworkLayer内のフォルダとファイルの構造は以下の通りになる。 [必須]と記載しているもの以外は実装を便利にしたり一部機能をDIにより差し替えるために記載しているもので無くても良い。

  • APIType
    • APIType.swift [必須]
    • Request.swift [必須]
    • ResponseSerializer.swift [必須]
  • Serializer
    • Serializable.swift [必須]
    • CodableSerializer.swift
  • Networking
    • APIType+dispatch.swift [必須]
    • RequestDispatchable.swift [必須]
    • APIError.swift [必須]
  • RequestDispatcher
    • URLSessionRequestDispatcher.swift [必須]
    • AlamofireRequestDispatcher.swift
    • StubRequestDispatcher.swift

1. API Type

APITypeは全てのスタートポイントでもっとも重要な部分である。APINetworkLayer全体もここを中心に設計されている。DDD的な言い方をすればコアドメインのようなもの。

これを独自に定義したAPI(ex. UserAPI)などのclassへ準拠させてAPIを作成する(正確にはほとんどのケースで一部共通処理を内包したAPIBaseType等を定義して、直接はそれをAPIのclassへ準拠させて使う。)

Swiftプロジェクトにおいて定義されるAPIは主に2つの責務を担っている。
一つはHTTPリクエストを送信すること、もう一つは帰ってきたレスポンンスからModel等のデータをパースして取り出すことである。これらの2つ含むprotocolをAPITypeとして定義すると以下のようになる。

APIType.swift
public protocol APIType {
    associatedtype ResponseType

    var request: Request { get }
    var responseSerializer: ResponseSerializer { get }
}

また返すmodelの型のタイプをResponseTypeとして保持している。

Request

APITypeのpropertyの内、HTTPリクエストを送る際に必要な情報を保持。SwiftのURLRequestのAPIリクエストに必要な情報のみを抜粋したようなもの。

Request.swift
public struct Request {

    // Property
    public let url    : String
    public let method : HTTPMethod
    public let params : [String: Any?]?
    public let headers: [String: String]?

    // Init
    public init (
        url    : String,
        method : HTTPMethod,
        params : [String: Any?]? = nil,
        headers: [String: String]? = nil
        ) {

        self.url = path
        self.method = method
        self.params = params
        self.headers = headers
    }
}

public enum HTTPMethod: String {
    case get    = "GET"
    case post   = "POST"
    case put    = "PUT"
    case delete = "DELETE"
    case patch  = "PATCH"
}

ResponseSerializer

HTTPリクエストから帰ってきたレスポンスをmodelに変換する部分。
全てのAPIにおいて共通しているのは、Data形式のレスポンスを受け取り、をれを指定した型のモデルへ変換して返すということである。

これをclosureとして宣言してtypealiasに差し込みそれがどのようなものかの名前を与える。

ResponseSerializer.swift
public extension APIType {
    typealias ResponseSerializer = ((Data) -> (ResponseType?)) 
}

ResponseSerializerへdataからmodelへ変換する処理が書かれているfunctionを差し込み、差し込まれたclosureが実際のdata -> modelへの変換処理を行う。

ResponseTypeはAPITypeで先ほど宣言したassociatedtypeでありAPITypeを準拠させた具体的なAPI class等で型が差し込まれる。genericにどのような型へも対応可能である。

dataを受けっとって特定の型を返すfunctionならあらゆるものが差し込めるため、serializeの実装方法に依存せずどのよな方法でのserializationの実装にも対応できる。

closureの内部がCodableで処理されていようと、SwiftyJSONで処理されていようと、Jsonを[String: Any]へ変換して手動で処理していようとAPITypeとResponseSerializerはそれを感知しない。

よってプロジェクトの事情等に応じてserializationの方法を途中で差し替えるのも容易である。

APITypeを使ってAPIを作成する例

実際にAPITypeを使いAPIを作成した場合はこのようになる。

SimpleUserAPI.swift
import APINetworkRequest

/**
    理解するためのサンプルなので現実のプロジェクトではすこし違った実装になる。

    現実のプロジェクトではBaseURLなど全てのAPIに共通する処理があるため、
    APITypeを直接特定のAPIへ準拠はさせずBaseAPIType等処理を共通化したprotocolを容易する。
*/
struct SimpleUserAPI: APIType {
    typealias ResponseType = [User]

    var request: Request = Request(url: "https://my-sample-site.co.jp/api/users",
                                   method: .get,
                                   params: nil,
                                   headers: nil)

    var responseSerializer: ((Data) -> ([User]?)) = { data in
        // ここにData型からResponseType(= [User])へ変換する処理を差し込む。
        // ほとんどの場合は他の場所で定義した汎用のSerializationコードを差し込むことになる。
        // これに関してはSerializerの項で解説
    }
}

2. Serializer

APITypeの項で全てのAPIはHTTPのレスポンスのData型のobjectを特定の型のmodelへ変換する必要があると解説した。Serialization配下にはこの処理に特化したパーツが配置されている。

Serializable

protocol Serializableではdataを受け取りgenericな指定された型を返すfunctionが定義されている。

Serializable.swift
public protocol Serializable {
    associatedtype SerializingType
    static func serialize(data: Data) -> SerializingType?
}

これを準拠させたたclassのserializeメソッドはAPITypeのresponseSerializerへ差し込むことが可能である。これらをAPITypeに差し込むことでAPIからserializationの処理を分離できる。

data -> modelの変換さえ行われていればその処理の詳細は何でもよくAPITypeからは処理が隠蔽され、Serializer classでは必要に応じて処理の方法をCodable, SwiftyJSON等選択&変更が可能であり、それをAPIへは影響を与えず行える。

CodableSerializer

必須ではないが、Codableを使ったSerializerを簡単に作成できる汎用struct。
以下のような書き方で簡単に特定のCodable準拠のmodelのserializeメソッドを作成できる。

CodableSerializer.swift
public struct CodableSerializer<T: Codable>: Serializable {

    public typealias SerializingType = T

    public static func serialize(data: Data) -> T? {

        do {
            let jsonDecoder = JSONDecoder()
            let serialized = try jsonDecoder.decode(SerializingType.self, from: data)
            return serialized

        } catch {
            return nil
        }
    }
}

APITypeに準拠したAPIのresponseSerializerの部分へserializeメソッドを差し込むこことで使用する。

SimpleUserAPI.swift
// SimpleUserAPI内での実装例
var responseSerializer: ((Data) -> ([User]?)) = CodableSerializer<[User]>.serialize

Serializableを使ったSerializerの作成例

UsersSerializer.swift
struct UsersSerializer: Serializable {

    typealias SerializingType = [User]

    // data -> [User]の変換さえ行われればどのような実装でも良い。
    // 2つの例を記載。サンプル用にserializeを複数回宣言しているのでコンパイル不可なので注意

    /**
     手動でのjsonパースの例
     通常はCodable等を使うのでこのような実装を行うことは少ない。
    */
    static func serialize(data: Data) -> SerializingType? {
        guard let json = data as? [[String: Any]] else { return nil }
        return json.map { User(json: $0) }
    }

    /**
     CodableSerializerを使った例。
     CodableSerializerを直接APIに差し込むより、ここで定義した方がDRY原則に
     沿っていておすすめ。
    */
    static func serialize(data: Data) -> SerializingType? {
        return CodableSerializer<SerializingType>.serialize(data: data)
    }
}

3. Networking

Networking配下には実際にRequestを送信したりそのレスポンスを処理した理するためのコンポーネントが配置されている。

APIType+dispatch

protocol APITypeに実際HTTPリクエストを送信させるメソッドを追加。Dispatcherは先に定義したRequestオブジェクトを使いHTTPリクエストを行いdataをレスポンスとして返す。

DispatcherはDI可能で実際のdispatch処理はどのように行われようとAPITypeは感知しない。URLSession, Alamofire, テスト用のStubなど様々な方法の実装に簡単に差し替えが可能である。

Dispatcherから帰ってきたDataはAPITypeが保持しているserializerメソッドでModelへ変換される。

APIType+dispatch.swift
public extension APIType {

    // デフォルトでは後述のURLSessionRequestDispatcherを使うようにしている
    public func dipatch(dispatcher: RequestDispatchable = URLSessionRequestDispatcher(),
                        onSuccess: @escaping (ResponseType) -> Void,
                        onError: @escaping (Error) -> Void) {

        dispatcher.dispatch(request: self.request,

            // Success
            onSuccess: { (responseData: Data) in

                guard let parsedData = self.responseSerializer(responseData) else {
                    onError(APIError.responseParseFailed)
                    return
                }

                DispatchQueue.main.async {
                    onSuccess(parsedData)
                }
            },

            // Erorr
            onError: { (error: Error) in
                DispatchQueue.main.async {
                    onError(error)
                }
            }
        )
    }
}

RequestDispatchable

APIType+dispatchのdispatchメソッドの部分でDispatcherをDI可能にするためのprotocol。

RequestDispatchable.swift
public protocol RequestDispatchable {
    func dispatch(request: Request,
                  onSuccess: @escaping (Data) -> Void,
                  onError: @escaping (Error) -> Void)
}

APIError

エラーは本記事の本質からはずれるのでかなり簡略に書いている。実際にプロジェクトで使う場合は必要なcase等を追加。

APIError.swift
public enum APIError: Swift.Error {
    case urlNotValid
    case noResponseData
    case responseParseFailed
}

4. Request Dispatcher

Request DispatcherにはRequestDispatchableに準拠した複数のdispatcherは定義されている。
ここでは3つ方法での実装を解説するが、実際のプロジェクトではおそらく全ては必要にならない。

URLSessionRequestDispatcher

ライブラリ等に頼らずURLSessionを使ってHTTPリクエストを実装するクラス。

URLSessionRequestDispatcher.swift
public struct URLSessionRequestDispatcher: RequestDispatchable {

    public init() {}


    public func dispatch(request: Request,
                         onSuccess: @escaping (Data) -> Void,
                         onError: @escaping (Error) -> Void) {

        guard let urlRequest = createURLRequest(by: request, onError: onError) else { return }

        URLSession.shared.dataTask(with: urlRequest) { (data, response, error) in

            // Error
            if let error = error {
                onError(error)
                return
            }

            // Check response error
            guard let data = data else {
                onError(APIError.noResponseData)
                return
            }

            // Success
            onSuccess(data)

        }.resume()
    }

    private func createURLRequest(by restURLRequest: Request, onError: (Error) -> Void) -> URLRequest? {

        // Create URL Request
        guard let url = URL(string: restURLRequest.url) else {
            onError(APIError.noResponseData)
            return nil
        }
        var urlRequest = URLRequest(url: url)

        // Set HTTP Method
        urlRequest.httpMethod = restURLRequest.method.rawValue



        // Set Param
        do {
            if let params = restURLRequest.params {
                urlRequest.httpBody = try JSONSerialization.data(withJSONObject: params, options: [])
            }
        } catch let error {
            onError(error)
            return nil
        }

        // Set Headers
        if let headers = restURLRequest.headers {
            urlRequest.allHTTPHeaderFields = headers
        }

        return urlRequest
    }
}

AlamofireRequestDispatcher

Alamofireを使った例

AlamofireRequestDispatcher.swift
import Alamofire


public struct AlamofireRequestDispatcher: RequestDispatchable {

    public init() {}

    public func dispatch(request: Request, onSuccess: @escaping (Data) -> Void, onError: @escaping (Error) -> Void) {

        let afRequest: DataRequest
        switch request.method {
        case .get:
            afRequest = AF.request(request.url, method: .get, parameters: request.params)

        case .post:
            afRequest = AF.request(request.url, method: .get, parameters: request.params)

        case .delete:
            afRequest = AF.request(request.url, method: .get, parameters: request.params)

        case .patch:
            afRequest = AF.request(request.url, method: .get, parameters: request.params)

        case .put:
            afRequest = AF.request(request.url, method: .get, parameters: request.params)
        }

        afRequest.validate()
            .responseJSON { response in

                guard response.result.isSuccess else {
                    print("")
                    onError(APIError.generalError)
                    return
                }

                guard let data = response.data else {
                    onError(APIError.noResponseData)
                    return
                }

                onSuccess(data)
        }
    }
}

StubRequestDispatcher

テスト等で使うためのStubの例

StubRequestDispatcher.swift
public struct StubRequestDispatcher: RequestDispatchable {

    public func dispatch(request: Request, onSuccess: @escaping (Data) -> Void, onError: @escaping (Error) -> Void) {
        onSuccess(Data())
    }

    public init() {}
}

プロジェクト側での実装

Codable準拠のUser modelのAPIを作成するという前提でのサンプルコードを記載。

User.swift
struct User: Codable {
    let id: Int
    let name: String
}

BaseAPI

base URLの共通化とenumを使った実際のAPIクラスでselfでのswitch分を書きやすくするための処理。
実際に作成するAPIへはこのprotocolを準拠させる。APITypeのWrapper。

BaseAPI.swift
import APINetworkRequest

protocol BaseAPIType: APIType {
    var httpMethod: HTTPMethod { get }
    var path: String { get }
    var params: [String: Any] { get }
}

extension BaseAPIType {

    var url: String {
        let baseURL = "https://my-sample-site.co.jp/"
        return baseURL + self.path
    }

    var request: Request {
        return Request(url: self.url,
                              method: self.httpMethod,
                              params: self.params,
                              headers: nil)
    }
}

UserSerializer

CodableSerializerを使ったUserSerializerを定義。

UserSerializer.swift
import APINetworkRequest

struct UsersSerializer: Serializable {

    typealias SerializingType = [User]

    static func serialize(data: Data) -> SerializingType? {
        return CodableSerializer<SerializingType>.serialize(data: data)
    }
}

UserAPI

enumへBaseAPITypeを準拠させることで複数のUserAPIを一括で管理させる。
必ずしもenumへ準拠させる必要はなくstructなどでも問題ない。

UserAPI.swift
import APINetworkRequest

enum UserAPI: BaseAPIType {

    case all
    case latest(requestParam: [String: Any]) // paramは本来もっとしっかり書くべき


    typealias ResponseType = [User]

    var path: String {
        switch self {
        case .all: return "users"
        case .latest: return "users/latest"
        }
    }

    var params: [String : Any] {
        switch self {
        case .all:
            return [:]
        case .latest(let requestParam):
            return requestParam
        }
    }

    var httpMethod: HTTPMethod {
        return .get
    }

    var responseSerializer: ((Data) -> ([User]?)) {
        // 先に別途定義したUserSerializerを使用
        return UsersSerializer.serialize
    }
}

実際のAPIの呼び出し

呼び出し時はdispatcherをDI可能。この部分までくると一般的なAPIの実装と大きな違いはない。

APICallSample.swift
        // 適当なparameterを準備
        let latestUsersRequestParam: [String: Any] = [
            "id": 1,
            "type": "mytype"
        ]

        /*
         実際の呼び出し。

         dispatcherは指定しなくてもデフォルトのものが差し込まれるが、
         例としてわかりやすくするために明示的に指定。

         AlamofireRequestDispatcherなど他のdispatcherをDIすることも可能。
        */
        UserAPI.latest(requestParam: latestUsersRequestParam).dipatch(
            dispatcher: URLSessionRequestDispatcher(),
            onSuccess: { users in
                print(users)
        },
            onError: { error in
                // エラー処理
        })

参考文献

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

[swift][iOS]RxTableViewに紐付く画像をlazy loadingする

TableViewに紐付く形でUIImageViewを設置

スクリーンショット 2019-03-18 16.34.50.png

こんな感じでUITableViewとcell、UIImageViewを設置。

lazy loadingを実装

https://stackoverflow.com/questions/28694645/how-to-implement-lazy-loading-of-images-in-table-view-using-swift
↑を参考に、swiftのバージョンを合わせて実装。

extension UIImageView {
    func downloadImageFrom(link: URL, contentMode: UIViewContentMode) {
        URLSession.shared.dataTask( with: link, completionHandler: { (data, _, _) -> Void in
            DispatchQueue.main.async {
                    self.contentMode = contentMode
                    if let data = data { self.image = UIImage(data: data) }
                }
            }
        ).resume()
    }
}

現時点で参考元と異なる点は、linkをstringではなくURLで貰っている点ぐらい。

dataSource周りは以下のように実装。

var photoURLs: [URL]! = [] // cellに表示したい画像URLの配列

private func configureData() {
    let dataSource = RxTableViewSectionedReloadDataSource<SectionData>(
        configureCell: { (_, tableView, indexPath, item) in
            let cell: HogeCell = (tableView.dequeueReusableCell(
                withIdentifier: "HogeCell",
                for: indexPath
                ) as? HogeCell)!

            cell.imageView.image = UIImage(named: "NoImage") // NoImageというLoading中を示す画像
            // ここでcellごとにlazy loadingを行う
            cell.imageView.downloadImageFrom(
                link: self.photoURLs[indexPath.row],
                contentMode: UIViewContentMode.scaleAspectFit,
            )
            return cell
    }, canEditRowAtIndexPath: { (_, _) in
        return true
    }, canMoveRowAtIndexPath: { (_, _) in
        return true
    }
    )

    self.imageItemsRelay.map {
        return [SectionData(header: "section1", items: $0)]
        }.bind(to: tableView.rx.items(dataSource: dataSource))
        .disposed(by: rx.disposeBag)

    self.tableView.rx.itemDeleted
        .subscribe(onNext: { [weak self] indexPath in
            guard let `self` = self else { return }
            // 何か処理
        }).disposed(by: rx.disposeBag)

    self.tableView.rx.itemMoved
        .subscribe(onNext: { [weak self] sourceIndexPath, destinationIndexPath  in
            // 何か処理
        }).disposed(by: rx.disposeBag)
}

問題点

これだとcellが画面外に出るとcellが再レンダリングされてしまい、その度にlazy loadingが走って画像を取得しに行ってしまう。(dequeueReusableCell使ってるのに。。。)
一度表示した画像はそのままに表示しておきたい。

原因

  • https://github.com/RxSwiftCommunity/RxDataSources より
  • dataSourceとして利用しているRxTableViewSectionedReloadDataSourceがSection単位でreloadData()をして表示内容を更新するようになっているため画像の再取得が行われてしまっている模様

対策

  • RxTableViewSectionedAnimatedDataSourceを使う
  • 初回にlazy loadingでDLしてきた画像を持っておいて、2回目以降はDLしてきた画像を使う

RxTableViewSectionedAnimatedDataSourceを使ってみる

  • RxTableViewSectionedAnimatedDataSourceは変化のあったcellだけ変更がかかるみたい
  • 置き換えてみたがダメだった
  • RxTableViewSectionedAnimatedDataSource内で使われている、古い配列と新しい配列を比較するアルゴリズムdifferencesForSectionedViewで比較ありと言われているっぽい?
  • アルゴリズムの中までは見れていないが、一旦こっちは諦めた

初回にlazy loadingでDLしてきた画像を持っておいて、2回目以降はDLしてきた画像を使う

タイトルのまま。
以下にような辞書を用意しておく。

var downloadedUIimages: [Int64: UIImage] = [:]

Int64は画像を一意で識別できるIDとか。
無いならURLでもいいかも。

var photoURLs: [URL]! = []
var downloadedUIimages: [Int64: UIImage] = [:]

private func configureData() {
    let dataSource = RxTableViewSectionedReloadDataSource<SectionData>(
        configureCell: { (_, tableView, indexPath, item) in
            let cell: HogeCell = (tableView.dequeueReusableCell(
                withIdentifier: "HogeCell",
                for: indexPath
                ) as? HogeCell)!            
           // DLしようとする画像が既にdownloadedUIimagesに存在していれば再利用する
            if let _downloadedImage = self.downloadedUIimages[item.imageId] {
                cell.imageView.image = _downloadedImage
            }
            else {
                cell.imageView.image = UIImage(named: "NoImage")
                cell.imageView.downloadImageFrom(
                    link: self.photoURLs[indexPath.row],
                    contentMode: UIViewContentMode.scaleAspectFit,
                    // 完了時にdownloadedUIimagesにimageIDとUIImageを紐づけて持っておく
                    completion: {
                        self.downloadedUIimages[item.imageId] = cell.imageView.image
                })
            }
            return cell
    }, canEditRowAtIndexPath: { (_, _) in
        return true
    }, canMoveRowAtIndexPath: { (_, _) in
        return true
    }
    )

    self.imageItemsRelay.map {
        return [SectionData(header: "section1", items: $0)]
        }.bind(to: tableView.rx.items(dataSource: dataSource))
        .disposed(by: rx.disposeBag)

    self.tableView.rx.itemDeleted
        .subscribe(onNext: { [weak self] indexPath in
            // 何か処理
        }).disposed(by: rx.disposeBag)

    self.tableView.rx.itemMoved
        .subscribe(onNext: { [weak self] sourceIndexPath, destinationIndexPath  in
            // 何か処理
            // 削除したならdownloadedUIimagesからも削除してもいいかもしれない
        }).disposed(by: rx.disposeBag)
}

extension UIImageView {
    func downloadImageFrom(link: URL, contentMode: UIViewContentMode, completion: (() -> Void)? = nil) {
        URLSession.shared.dataTask( with: link, completionHandler: { (data, _, _) -> Void in
            DispatchQueue.main.async {
                    self.contentMode = contentMode
                    if let data = data { self.image = UIImage(data: data) }
                    completion?()
                }
            }
        ).resume()
    }
}

上記のようにすることで、スクロールしてcellが画面外に行っても画像の再DLが行われることはなくなった。

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

fastlaneのビルドを数秒短縮できるかもしれないtips

タイトルで全部説明出来そうだったので、思わせぶりなタイトルになってしまった。

対象

  • CarthageやCocoapodsを使っている
  • fastlaneでbadgeプラグインを使っている

上記に当てはまらない方は関係ないです。

不要なバッジ処理

アイコンにバージョンバッジを付けたい場合Fastfileで以下のように書くことが多いと思う。

add_badge(shield: "#{@version_number}-#{@commit_hash}-white")

が、このような書き方で実行すると、

\[13:50:07]: Start adding badges...
\[13:50:07]: '../App/Carthage/Module/Assets/Assets.xcassets/AppIcon.appiconset/Icon-App-iTunes.png'
\[13:50:08]: '../App/Carthage/ModuleB/Assets/Assets.xcassets/AppIcon.appiconset/Icon-App-iTunes.png'
...

のような感じで、CarthageやCococaPodsでCheckoutしたExampleプロジェクト内のアイコンにも処理が行われることがある。(これが十数秒かかったりする)
なので、

add_badge(shield: "#{@version_number}-#{@commit_hash}-white", glob: "/App/Core/Assets/Assets.xcassets/AppIcon.appiconset/*.{png,PNG}")

といった感じでglobでメインのアプリだけを指定してあげれば、この無駄な処理が省ける。
globは内部でcurrent_pathと連結されるので/から始める点に注意。

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

WKWebViewでBasic認証を実装する

WKWebViewでBasic認証を実装する

WKWebViewでBasic認証を実装するサンプルコードです。

環境
Xcode10.1

最終的なサンプルを見たいならこちら

WebViewの生成

WKWebViewを生成しdelegateの設定を行います。
認証に関するdelegateは navigationDelegate です。(WKWebViewには uiDelegateもあるので気をつけて。)

以下はコードで生成する例です(Storyboardでも問題なし)。

ViewController.swift
import UIKit
import WebKit

class ViewController: UIViewController {

    var webView: WKWebView!

    override func loadView() {
        view = UIView(frame: .zero)

        let configuration = WKWebViewConfiguration()
        webView = WKWebView(frame: .zero, configuration: configuration)
        webView.translatesAutoresizingMaskIntoConstraints = false
        webView.navigationDelegate = self
        view.addSubview(webView)

        NSLayoutConstraint.activate([
            webView.topAnchor.constraint(equalTo: view.topAnchor),
            webView.leftAnchor.constraint(equalTo: view.leftAnchor),
            webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            webView.rightAnchor.constraint(equalTo: view.rightAnchor)
            ])
    }

}

extension ViewController: WKNavigationDelegate {
}

認証のdelegateを実装

認証に関するdelegateメソッドを実装します。
まずは何もしないメソッドを書きます。

ViewController.swift
extension ViewController: WKNavigationDelegate {

    public func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
        switch challenge.protectionSpace.authenticationMethod {
        default:
            completionHandler(.performDefaultHandling, nil)
        }
    }

}

Basic認証の処理を実装

認証に関するdelegateメソッドは、Basic認証に限らず他の認証でも呼ばれます。
Basic認証の場合の処理と、それ以外の処理を分けて実装します。

URLCredentialにIDとPasswordを設定して、completionHandlerを呼び出します。

ViewController.swift
extension ViewController: WKNavigationDelegate {

    public func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
        switch challenge.protectionSpace.authenticationMethod {
        case NSURLAuthenticationMethodHTTPBasic:
            let credential = URLCredential(user: "user", password: "password", persistence: URLCredential.Persistence.forSession)
            completionHandler(.useCredential, credential)

        default:
            completionHandler(.performDefaultHandling, nil)
        }
    }

}

サンプル

UIAlertControllerでユーザー人入力を求める場合の最終的なコードは以下になります。

IDとPasswordを入力したとき、キャンセルしたとき、そのそれぞれでcompletionHandlerを呼び出します。

ViewController.swift
import UIKit
import WebKit

class ViewController: UIViewController {

    var webView: WKWebView!

    override func loadView() {
        view = UIView(frame: .zero)

        let configuration = WKWebViewConfiguration()
        webView = WKWebView(frame: .zero, configuration: configuration)
        webView.translatesAutoresizingMaskIntoConstraints = false
        webView.navigationDelegate = self
        view.addSubview(webView)

        NSLayoutConstraint.activate([
            webView.topAnchor.constraint(equalTo: view.topAnchor),
            webView.leftAnchor.constraint(equalTo: view.leftAnchor),
            webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            webView.rightAnchor.constraint(equalTo: view.rightAnchor)
            ])
    }

    override func viewDidLoad() {
        let request = URLRequest(url: URL(string: "https://hogehoge.com/")!)
        webView.load(request)
    }
}

extension ViewController: WKNavigationDelegate {

    public func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
        switch challenge.protectionSpace.authenticationMethod {
        case NSURLAuthenticationMethodHTTPBasic:

            let alert = UIAlertController(title: "Basic認証", message: "ユーザ名とパスワードを入力してください", preferredStyle: .alert)
            alert.addTextField {
                $0.placeholder = "user"
            }
            alert.addTextField {
                $0.placeholder = "password"
                $0.isSecureTextEntry = true
            }

            let login = UIAlertAction(title: "ログイン", style: .default) { (_) in
                guard
                    let user = alert.textFields?[0].text,
                    let password = alert.textFields?[1].text
                    else {
                        completionHandler(.cancelAuthenticationChallenge, nil)
                        return
                }
                let credential = URLCredential(user: user, password: password, persistence: URLCredential.Persistence.forSession)
                completionHandler(.useCredential, credential)
            }
            let cancel = UIAlertAction(title: "キャンセル", style: .cancel) { (_) in
                completionHandler(.cancelAuthenticationChallenge, nil)
            }
            alert.addAction(login)
            alert.addAction(cancel)

            present(alert, animated: true, completion: nil)

        default:
            completionHandler(.performDefaultHandling, nil)
        }
    }

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

iOS、Safariの<input type="file" multiple>で発生するエラーに対応した際の備忘録

はじめに

とあるサイトを制作していた時、iOS、Safariの<input type="file" multiple>でファイルを選択した場合にエラーが発生する事象に遭遇しました。
ネット上で有益な情報を見つけられなかったので、備忘録として残します。

事象

下記のような、選択したファイル名をリスト表示させるものを作る際に発生しました。

ソース

HTML
<form action="" method="get">
<div>
<ul>
</ul>
<label>
<input type="file" multiple="multiple">
</label>
</div>
</form>
JavaScript
$(function () {
  /*------------------------------------------
    ファイルアップロード
  --------------------------------------------*/ 
  $('body').on('change', 'input[type="file"]', function() {
    var $this = $(this)
    var $ul = $this.parent('label').siblings('ul')
    var listHtml = ''

    if($ul.length > 0) {
      var files = $this[0].files
      var filesLength = files.length

      // ファイルを1件以上選択している場合
      if(filesLength > 0) {
        // HTMLを生成
        for(var i = 0; i < filesLength; i++) {
          listHtml += '<li>' + files[i].name + '</li>'
        }

        // HTMLに反映
        $ul.html(listHtml)
      } else {
        // ファイルの選択をキャンセルした場合
        // リストを削除する
        $ul.html('')
      }
    }
  })
})

See the Pen MultipleFileSample01 by N/NE (@inumberx) on CodePen.

発生端末

OS iOS 12.1.4
端末 iPhoneX
ブラウザ Safari、Google Chrome

現象

<input type="file" multiple>でファイルを複数選択した後に、再度ファイルを再選択・キャンセルすると下記画像のようなエラーが発生するというものです。

img_001.jpg

  • 問題が発生したため、このWebページが再読み込みされました。というメッセージが表示される

img_002.jpg

  • "https://XXX/YYY/ZZZ"で問題が繰り返し起きました。というメッセージが表示される

発生タイミング

色々と検証した結果、エラーは下記のようなタイミングで発生しているようでした。

  • ファイル選択済の状態でファイルを再選択する
  • ファイル選択済の状態でキャンセルする
  • ファイルを選択した時に、既にファイル選択済の他の<input type="file" multiple>が表示状態(display:none;以外)で存在する

対応方法

エラーを発生させないためには、ファイルを選択した時に既にファイル選択済の<input type="file" multiple>がdisplay:none;以外で存在していてはいけません。
そこで、ファイルを選択した時にJS(jQuery)でその要素を非表示にし、新しく空の<input type="file" multiple>を作ることによってエラーが発生しないようにしました。

ソース

HTML
<form action="" method="get">
<div>
<ul>
</ul>
<label>
<input type="file" multiple="multiple">
</label>
</div>
</form>
CSS
input[type="file"][multiple="multiple"].ios {
position: absolute;
opacity: 1;
z-index: 1;
}
input[type="file"][multiple="multiple"].ios + input[type="file"][multiple="multiple"] {
display: none;
}
JavaScript
$(function () {
  /*------------------------------------------
    ファイルアップロード
  --------------------------------------------*/
  // iOSの場合
  // すでにファイルを選択済みの<input type="file" multiple>の場合、ファイルを再選択・キャンセルした時にブラウザのエラーが発生するので個別対応を行う
  if(isIOs && $('input[type="file"][multiple="multiple"]').length > 0) {
    var $file = $('input[type="file"][multiple="multiple"]')
    var fileLength = $file.length

    for(var i = 0; i < fileLength; i++) {
      var $thisFile = $($file[i])
      // inputを複製
      var $cloneFile = $thisFile.clone(true)
      // 複製した要素にクラスを付与
      $cloneFile.val('').addClass('ios')
      // 複製した要素はabsoluteで複製元の要素に被せるため、親要素をrelativeにする
      $thisFile.parent().css(
        {
          'position': 'relative',
          'min-width': $thisFile.outerWidth(true) + 'px',
          'min-height': $thisFile.outerHeight(true) + 'px'
        }
      );
      // 複製した要素をDOMに追加
      $thisFile.before($cloneFile)
    }

    // 複製した要素に隣り合う<input type="file" multiple>がタッチされた時の処理
    $('body').on('touchstart', 'input[type="file"][multiple="multiple"].ios + input[type="file"][multiple="multiple"]', function() {
      // 何もしない
      return false
    })

    // サブミット時の処理
    $('body').on('submit', function() {
      // 複製した要素を削除する
      $('input[type="file"][multiple="multiple"].ios').remove()
    })
  }

  $('body').on('change', 'input[type="file"]', function() {
    var $this = $(this)
    var $ul = $this.parent('label').siblings('ul')
    var listHtml = ''

    if($ul.length > 0) {
      var files = $this[0].files
      var filesLength = files.length

      // ファイルを1件以上選択している場合
      if(filesLength > 0) {
        // HTMLを生成
        for(var i = 0; i < filesLength; i++) {
          listHtml += '<li>' + files[i].name + '</li>';
        }

        // HTMLに反映
        $ul.html(listHtml)
      } else {
        // ファイルの選択をキャンセルした場合
        // リストを削除する
        $ul.html('')
      }
    }

    // iOSの場合
    if($this.hasClass('ios')) {
      // inputを複製
      var $cloneFile = $this.clone(true)
      // 複製した要素のvalueを空にする
      $cloneFile.val('')
      // すでに存在するinputを削除する
      $this.siblings('input[type="file"][multiple="multiple"]').remove()
      // 複製した要素をDOMに追加
      $this.removeClass('ios').before($cloneFile)
    }
  })
})

/*------------------------------------------
 iOSチェック
--------------------------------------------*/
function isIOs() {
  var ua = navigator.userAgent
  var bw = window.navigator.userAgent.toLowerCase()

  // iOSの場合
  if(ua.indexOf('iPhone') > 0 || ua.indexOf('iPod') > 0 ) {
    return true
  }

  return false
}

See the Pen MultipleFileSample02 by N/NE (@inumberx) on CodePen.

問題点

  • 常に「ファイル未選択」と表示される → 選択したファイル数を「ファイル未選択」の文言の上に無理やり被せる?
  • 根本的な解決方法ではない気がする → もっと良い解決方法をご存知の方がいらっしゃったらご教示ください
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【VisualStudio】新規プロジェクト製作時、ターゲットプラットフォームが選択できないときの対処法

問題発生

スクリーンショット 2019-03-15 午後1.05.56.png
ネイディ部アプリを制作しようと新規プロジェクト制作しようとしたところ、ターゲットプラットフォームのチェックボックスがグレーになっていて選択できない!

解決法

インストーラをもう一度実行する
スクリーンショット 2019-03-15 午後1.13.22.png
チェックボックスが外れた状態のiOS,Androidの項目を発見
チェックしてインストールを開始します。

結果

スクリーンショット 2019-03-15 午後1.58.12.png
無事に選択できるようになりました✌

最後に

解決方法は簡単でしたが、意外と詰まってしまいました。
同じ症状に陥ってしまった方の参考になれば幸いです。

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

Flutterウィークリー #50

Flutterウィークリーとは?

FlutterファンによるFlutterファンのためのニュースレター
https://flutterweekly.net/

この記事は#50の日本語訳です
https://mailchi.mp/flutterweekly/flutter-weekly-50

※Google翻訳を使って自動翻訳を行っています。翻訳に問題がある箇所を発見しましたら編集リクエストを送っていただければ幸いです。

読み物&チュートリアル

Flutter 1.2:このリリースの新機能

https://hackernoon.com/flutter-1-2-whats-new-in-this-release-799062b36c36


Dart 1.2の新機能のレビュー

Flutterの主な違い:すべてのピクセルを所有する

https://medium.com/flutter-community/flutters-key-difference-owning-every-pixel-e2135b44c8a


Tom Gilderが、 Flutterが画面をどのように使用し、他のフレームワークと比較するかをレビューします。

Flutter TikTokのUIを構築する - パート2:小さな部品を構築する

https://medium.com/filledstacks/building-tiktoks-ui-in-flutter-part-2-build-the-small-parts-42fb2089d605?sk=b5d69e88615c356eac333436e4d4c04e


Flutter TikTok UIを作成する方法に関するDane Mackierによるチュートリアル。

Flutterアニメーションはかつてないほど簡単になりました - パート1

https://medium.com/flutter-community/flutter-animation-has-never-been-easier-part-1-e378e82b2508


このチュートリアルの最初の部分で、Mellati MeftahがFlutterでのアニメーションの仕組みについて説明します。

Dartは元気で落ち着いているはず

https://tudorprodan.com/dart-is-fine/


なぜDart for Flutterを使用するかについての議論についてのTudor Prodan。

Flutter素晴らしいログイン画面を作成する

https://medium.com/flutter-community/creating-awesome-login-screen-in-flutter-88d46c0d76ae


Subir Chakrabortyによるこのチュートリアルに従って、クールなログイン画面を作成してください。

Dart演算子を使った簡単でバグのないコード

https://medium.com/@dev.n/simple-and-bug-free-code-with-dart-operators-2e81211cecfe


Deven Joshiが最も強力なDartオペレータをレビューします。

ツールボックス - カスタムフォントの使い方

https://fluttersensei.com/posts/the-toolbox-how-to-use-custom-fonts/


Flutterプロジェクトでカスタムフォントを使用することがどれほど簡単かを学びましょう。

UI2CODEの紹介:自動Flutter UIコードジェネレータ

https://medium.com/@alitech_2017/introducing-ui2code-an-automatic-flutter-ui-code-generator-7e0a575c193


Alibaba Techチームは、設計からFlutterコードを自動的に生成するための新しいツールを紹介します。

Flutterスケッチします。自動的に

https://blog.prototypr.io/sketch-to-flutter-automatically-cf693ea1c892


Supernova、SketchデザインからFlutter用のコードを自動的に生成することを可能にするツールへのイントロ。

Flutterにおける非対称鍵生成

https://medium.com/flutter-community/asymmetric-key-generation-in-flutter-ad2b912f3309


GonçaloPalmaが非対称鍵生成のためにあなたのFlutterアプリにPointy Castleを統合する方法を説明します。

FlutterピアノをFlutter

https://rodydavisjr.com/2019/03/12/making-a-piano/

Rody DavisがFlutterピアノを作成する方法を説明します。ソースへのリンクが含まれています。

debugPrintと、 Dartログを非表示にしてカスタマイズする機能

https://medium.com/flutter-community/debugprint-and-the-power-of-hiding-and-customizing-your-logs-in-dart-86881df05929


GonçaloPalmaによるアプリの正しい方法でのログインに関する記事。

FlutterレディFlutterゴー(フレーバー、コネクティビティなど)

https://medium.com/flutter-community/flutter-ready-to-go-e59873f9d7de


Julio Henrique Bitencourtが彼のすぐに使えるテンプレートプロジェクトの使い方を説明します。

ビデオ&メディア

Flutterステートマネジメント - グランドツアー

https://www.youtube.com/watch?v=3tm-R7ymwhc


Flutter 10の素晴らしい状態管理技術のツアーに参加してください

MLキット( Flutter In Focus)を使用したコンピュータビジョン

https://www.youtube.com/watch?v=ymyYUCrJnxU&list=PLjxrf2q8roU2HdJQDjJzOeO6J3FoFLWr2&index=13&linkId=64757853


Matt Sullivanが、 Flutter ML Kit Visionプラグインを使用する方法を説明します。

Flutter 2019:FloatingActionButtonのような新しいGmailを作る

https://www.youtube.com/watch?v=fiOAAiZ41Zs&feature=youtu.be


フラッターでカラフルなフローティングアクションボタンのような新しいGmailを作成する方法に関するビデオ

Flutterチュートリアル - Flutterグーグルマップ

https://www.youtube.com/watch?v=lNqEfnnmoHk


このビデオでは、google_maps_flutterプラグインを使用して、フラッターアプリケーションにGoogleマップを統合する方法を説明します。

Flutter UI - 最小限のデザイン - 植物

https://www.youtube.com/watch?v=ok5zoeE_5x0&feature=youtu.be


Raja YoganがFlutter植物カタログのUIを作成することによる素晴らしい挑戦です。

ライブラリ&コード

pichillilorenzo / flutter_inappbrowser

https://github.com/pichillilorenzo/flutter_inappbrowser

インラインWebビューを追加したり、アプリ内のブラウザウィンドウを開くことができるFlutterプラグイン(人気のcordova-plugin-applbrowserに触発されています)。

アニメーションストリームリスト

https://gitlab.com/otsoaUnLoco/animated-stream-list


ストリームからのアニメーション化された変更を含むリストを簡単に表示するためのFlutterライブラリ。

Vanethos / stream_disposable

https://github.com/Vanethos/stream_disposable

StreamSubscriptionsを破棄するためのStreamDisposableヘルパークラス

samarthagarwal / Flutterスクリーン

https://github.com/samarthagarwal/FlutterScreens

Flutterを使用して作成された画面と魅力的なUIの集まりで、アプリケーションで使用することができます。

thosakwe / vim-flutter

https://github.com/thosakwe/vim-flutter

Flutter用のVimコマンド。保存時のホットリロードなど。

AppleEducate / gmail_clone

https://github.com/AppleEducate/gmail_clone

Flutter作られたGmailクローン

pichillilorenzo / flutter_inappbrowser

https://github.com/pichillilorenzo/flutter_inappbrowser

インラインのWebビューを追加したり、アプリ内のブラウザウィンドウを開いたりできるFlutterプラグイン。

rxlabz / flutter_animation_explorer

https://github.com/rxlabz/flutter_animation_explorer

さまざまなアニメーションウィジェットで遊べるアプリです。

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

スマート家電コントローラSmart Lifeで出来ること出来ないこと

スマート家電コントローラでできることをまとめてみた

使ったもの

  • 買った家電コントローラ Iriscargo
  • スマホ iPhone 7, iOS 12.1.4
  • スマホアプリ Smart Life 3.8.5
    スマート家電コントローラの機能と使い勝手の9割はスマホアプリで決まります。
  • Google Home
    miniではない方です
  • テレビ Panasonic REGZA49G20X
  • 照明(シーリングライト) NEC製
    照明器具のプリセット設定はSmart Lifeに用意されていません。DIYで設定します。
  • エアコン 三菱電機霧ヶ峰MSZ-GV283,2014年製

できることできないこと

操作 Smart Life
アプリ
Siri Google Home
アプリ
Google Home
音声入力
テレビを点けて
(テレビのオン・オフ)
できる できる できる できない
テレビのボリュームを下げて できる できる できない できない
テレビのチャンネルを変えて できる できない できない できない
明かりを点けて
(照明のオン・オフ)
できる できない できない できない
エアコンを点けて
(エアコンのオン・オフ)
できる できる できる できる
エアコンの温度を20度にして できる できる できない できない

IFTTTを使わない場合です。IFTTTを使うと「テレビを点けて」がGoogle Home音声入力で可能になるが、ボリュームやチャンネルは変更できないと思います(未検証)。

総論

私にとってはGoogle Homeの音声入力でテレビのチャンネルを変えたかったのでSmart Lifeはそれができず機能不足でした。IFTTTを使うとテレビの電源のオン・オフだけできそうです。

設定でハマッタ点

  • 私の環境の場合、Google HomeのアカウントとSmart Lifeのアカウントのメールアドレスを同一にする必要がありました。気づいて設定を変更したのですが、401 auth permissionエラーが出ました。結局、iPhoneの設定-Safari-履歴とWebサイトデータを消去、することで連携させることができました。一度、アカウントエラーが出ると履歴を消す必要があるようです。
  • 家電コントローラIriscargoをスマホアプリとペアリングしてWifi設定を入力するためにはIriscargoのリセットボタンを長押ししてペアリングモードにする必要があります。リセットボタン長押しの動作が紙のマニュアルには書いてありませんでした。ペアリングモードではステータスインジケータが1秒おきに点滅します。私はつまようじで長押ししました。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Nginx,Redis,MySQLを使ってほんの少し実践的なRails ActionCableと、iOS/Androidのサンプルアプリを作って全体像を学ぶ〜Android編〜

前置き

「Rails ActionCableで双方向通信してみたい」「モバイルアプリでリアルタイム通信アプリ作りたい」と思いサンプルアプリを作ってみました。個々の詳細については既に解説してくださっている記事はありますので、大まかに環境構築やソースコードを記事にします。以下の3部構成になっています。

お遊びサンプルの紹介

以下のアニメーションGIF(ちょっと荒いですね:no_mouth:)をご覧ください。各ユーザーのアクティブ状況を表示して、メッセージをやりとります。もっと砕けた表現をするならば、筆者の愛犬たちが寝起きして、鳴いたり、遠吠えしたり、唸ったりします。
android_demo.gif

このアプリでは大きく2つのActionCableの使い方があります。

  • 同じルーム内の全ユーザーにブロードキャスト
    • ルームに入る
      現在ルーム内にいるユーザー(以下、アクティブユーザー)にルームに入ったことを通知し、アクティブユーザーを取得します。 そして、各ユーザーのアクティブ状況を表示します。
    • 「ワンワン」ボタンと「ワオーーン」ボタン
      文字入力で任意の文字が送れないだけで、チャットでいうところの「メッセージ」とほぼ同義です。ボタンに対応したメッセージを送信します。
      ※アニメーションGIFでは「わんわん」「ワオォーン」になっています。途中から文字を修正しました:bow:
    • ルームから出る
      「←」をタップしたり、アプリを閉じたりするとルームから出たことにします。ルームに入るときと同様にアクティブユーザーを取得して、各ユーザーのアクティブ状況を表示します。
  • 自分にブロードキャスト
    • 「独り言」
      「独り言」ということで自分のみメッセージを受信します。

構成

名前 バージョン
macOS Mojave 10.14.3
AndroidStudio 3.3.2
Kotlin 1.3.21
AVD(API) Pixel(28), Pixel2(27 / 28)
  • hosts(AVD)
    よろしければ筆者の記事をご覧ください。筆者はこのサンプルアプリを作る過程でAVDのhostsについて知り記事にしました。

    hosts
    10.0.2.2       devnokiyo.example.com
    

ソースコードはGitHubに公開しています。よろしければご覧ください。

actioncable-client-javaを導入する

ライブラリを利用して開発しますのでgradleに追記してAndroidStudioで同期します。

  • build.gradle
    01.png

  • app/build.gradle
    02.png

ソースコードの説明

サンプルアプリはActionCableと本質的に関係ない部分も多いので、GitHubに公開しているソースコードの原形を保ちながら主要な部分を抽出しました。ソースコードの中にコメントで説明します。

app/src/main/java/com/devnokiyo/actioncableapp/activities/BarkActivity.kt
class BarkActivity : AppCompatActivity() {
    private val cableUrl = "ws://devnokiyo.example.com/cable"   // ActionCableのコネクションのエンドポイント
    private val channelIdentifier = "RoomChannel"               // チャンネル名

    private lateinit var client: Consumer                       // ActionCableのコネクションに関連するインスタンス
    private lateinit var channel: Subscription                  // ActionCableのチャンネルを関連するインスタンス
    private var handler = Handler()                             // メインスレッドで実行するハンドラー

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_bark)
        // 「ワンワン」ボタンなどのリスナーを設定する。
        initListener()
    }

    override fun onResume() {
        super.onResume()
        // ActionCable関連のインスタンスを初期化・接続する。
        initClientAndChannel()
    }

    override fun onPause() {
        super.onPause()
        // 画面を閉じるときはコネクションを切断する。
        // サンプルでは便宜上onPause()で実装しておく。
        client.disconnect()
    }

    private fun initListener() {
        bawBawButton.setOnClickListener {
        // 「ワンワン」ボタン押下時はRoomChannelのbarkアクションを呼出す。
        // 送信情報) "content":"bawbaw"
            bark(bark = "bawbaw")
        }
        WaooonButton.setOnClickListener {
        // 「ワオーーン」ボタン押下時はRoomChannelのbarkアクションを呼出す。
        // 送信情報) "content":"waooon"
            bark(bark = "waooon")
        }
        mumblingButton.setOnClickListener {
        // 「独り言」ボタン押下時はRoomChannelのmumblingアクションを呼出す。
        // 送信情報) 無し。アクションを呼出すのみ。
            channel.perform("mumbling")
        }
    }

    private fun initClientAndChannel() {
        // コネクションのエンドポイントを指定する。
        // 送信情報) 呼出し元アクティビティから取得したaccount
        // 【補足】ユーザーの割出しと認証が必要ならOpenID Connectのアクセストークンなどになると思います。
        client = ActionCable.createConsumer(URI("$cableUrl/?account=$account"))

        // チャンネルを作成する。サブスクライブは手動で行う。
        // 送信情報) "room":ルーム名(ID)
        channel = client.subscriptions.create(Channel(channelIdentifier).apply { addParam("room", room) })

        channel.onConnected {
            // サブスクライブしたら、ルームに入ったことになる。
            // 同じルームのアクティブユーザーに通知するのでRoomChannelのgreetingアクションを呼出す。
            channel.perform("greeting")
        }

        channel.onReceived { data ->
            // UIスレッド(メインスレッド)で実行する。
            handler.post {
                // 自他問わずアクティブユーザーより送信された情報をこのコールバックで受信する。
                RoomChannelResponse.create(data)?.let { response ->

                    // 誰に関する情報か判定する。自身も含まれる。
                    // 受信情報) "account":ユーザーのアカウント
                    // findUserStatusViewメソッドは以下のいずれかのクラス変数を返却する。
                    // chiyoUsv
                    // eruUsv
                    // otomeUsv
                    val userStatusView = findUserStatusView(response.account)

                    when (response.type) {
                        SocketType.RoomIn -> {
                            // ルームに入ったらユーザーのステータスを更新する。
                            // 受信情報) "type":"in"
                            // 受信情報) "roommate":"[アクティブユーザーのアカウント...]"
                            // updateUserStatusメソッドはアクティブユーザーを以下のようにする。
                            // 表示内容:(^○^)
                            // オンラインの色(緑)
                            updateUserStatus(accounts = response.roommate, type = response.type)

                            // ルームに入ったユーザーは挨拶する。
                            // 受信情報) "type":"in"            表示内容: (^○^) (言語:日本語)
                            // getResourceStringメソッドはstrings.xml内の指定したname属性の内容を取得する。
                            userStatusView.bark.text = getResourceString(response.type.rawValue)
                        }
                        SocketType.RoomOut -> {
                            // ルームから出たユーザーは挨拶する。
                            // 受信情報) "type":"out"           表示内容: ( ˘ω˘ ) (言語:日本語)
                            userStatusView.bark.text = getResourceString(response.type.rawValue)

                            // オフラインの色(赤)に変更する。
                            userStatusView.online.setBackgroundColor(Color.RED)
                        }
                        SocketType.Mumbling -> {
                            // 受信情報)
                            //  "type":"mumbling"
                            //  "content":"(゚Д゚;)"             表示内容: (゚Д゚;) (言語:不問) バックエンドの固定値なので言語設定に依存しない
                            // 【補足】「独り言」は自身が送信した情報をActionCableを経由して自身のみが受信します。
                            response.content?.let { content ->
                                userStatusView.bark.text = content
                                userStatusView.rockBark()
                            }
                        }
                        SocketType.Bark -> {
                            // 受信情報)
                            //  "type":"bark"
                            //  "content":"bawbaw" / "wooon"  表示内容: ワンワン / ワオーーン (言語:日本語)
                            response.content?.let { content ->
                                userStatusView.bark.text = getResourceString(content)
                                userStatusView.rockBark()
                            }
                        }
                    }
                }
            }
        }

        // 【補足】他にも以下のコールバックが用意されています。
        // channel.onFailed { e: ActionCableException -> }
        // channel.onDisconnected {}
        // channel.onRejected {}

        // 【補足】以下のプロパティで再接続のポリシーを設定出来るようです。(厳密に確認していません。)
        // val options = Consumer.Options().apply {
        // reconnection = true
        // reconnectionMaxAttempts = 30
        // reconnectionDelay = 3
        // reconnectionDelayMax = 30
        // }
        // client = ActionCable.createConsumer(URI("$cableUrl/?account=$account"), options)

        // コネクションに接続してチャンネルをサブスクライブする。
        client.connect()
    }

    private fun bark(bark: String) {
        // 「ワオーーン」ボタン押下時はRoomChannelのbarkアクションを呼出す。
        // 送信情報) "content":bark
        channel.perform("bark", JsonObject().apply { addProperty("content", bark) })
    }

    // res/values/strings.xml内の指定したname属性の内容を取得する。
    private fun getResourceString(name: String): String =
        getString(resources.getIdentifier(name, "string", packageName))
}

終わりに

チャットアプリのサンプルが定番なので、少し違うアプローチでサンプルを作っていたはずなのですが、結局仕組みは似たり寄ったりになってきました。iOS版ではハマったことがありましたが、iOS版がある程度仕上がってからAndroid版を実装したので仕様でハマるところはありませんでした。Xcode/Swiftを見ながらAndroidStudio/Kotlinへ書写した感じですね。iOS版同様に細かい作込みはしていませんが、バックエンドとアプリの双方を実装して、やりたいことの表現と仕組みを概ね理解することができました。

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

Nginx,Redis,MySQLを使ってほんの少し実践的なRails ActionCableと、iOS/Androidのサンプルアプリを作って全体像を学ぶ〜iOS編〜

前置き

「Rails ActionCableで双方向通信してみたい」「モバイルアプリでリアルタイム通信アプリ作りたい」と思いサンプルアプリを作ってみました。個々の詳細については既に解説してくださっている記事はありますので、大まかに環境構築やソースコードを記事にします。以下の3部構成になっています。

お遊びサンプルの紹介

以下のアニメーションGIFをご覧ください。各ユーザーのアクティブ状況を表示して、メッセージをやりとります。もっと砕けた表現をするならば、筆者の愛犬たちが寝起きして、鳴いたり、遠吠えしたり、唸ったりします。
ios_demo.gif

このアプリでは大きく2つのActionCableの使い方があります。

  • 同じルーム内の全ユーザーにブロードキャスト
    • ルームに入る
      現在ルーム内にいるユーザー(以下、アクティブユーザー)にルームに入ったことを通知し、アクティブユーザーを取得します。そして、各ユーザーのアクティブ状況を表示します。
    • 「ワンワン」ボタンと「ワオーーン」ボタン
      文字入力で任意の文字が送れないだけで、チャットでいうところの「メッセージ」とほぼ同義です。ボタンに対応したメッセージを送信します。
      ※アニメーションGIFでは「ワオーン」とか「ワオォーン」になっています。途中から文字を修正しました:bow:
    • ルームから出る
      「キャンセル」をタップしたり、アプリを閉じたりするとルームから出たことにします。ルームに入るときと同様にアクティブユーザーを取得して、各ユーザーのアクティブ状況を表示します。
  • 自分にブロードキャスト
    • 「独り言」
      「独り言」ということで自分のみメッセージを受信します。

構成

名前 バージョン
macOS Mojave 10.14.3
Xcode 10.1
Swift 4.2.1
iOS 12.1
シミュレーター iPhone 6 / 7 / 8
  • hosts(macOS)

    hosts
    127.0.0.1       devnokiyo.example.com
    

ソースコードはGitHubに公開しています。よろしければご覧ください。

ActionCableClientを導入する

ライブラリを利用して開発します。少し変則的な導入をしているので説明します。

まず通常どおりCocoaPodsで導入する

公式の説明どおり一般的な導入をまず行います。

Podfile
pod "ActionCableClient"
$ pod install

ビルドエラーを解消する

残念ながら現行バージョン(0.2.3)ではSwift4.2に対応しきれていないようで、筆者の環境ではビルドエラーが発生してしまいます。また、開発中と思われる最新のコードはビルドは通りますが動作が不安定です。(バージョンのタグ付けがないので不安定なのは当たり前ですね。)
同件と思われるissueに便乗しつつ、今回はビルドエラーの箇所を修正することにしました。

  • Pods/ActionCableClient/Source/Classes/RetryHandler.swift 01.png
  • noshilan/Pods/Starscream/Source/WebSocket.swift 02.png

今回はこの修正がありますのでCocoaPodsでインストールしたライブラリもGitの管理対象に含めました。

ソースコードの説明

サンプルアプリはActionCableと本質的に関係ない部分も多いので、GitHubに公開しているソースコードの原形を保ちながら主要な部分を抽出しました。ソースコードの中にコメントで説明します。

ActionCableApp/ViewControllers/BarkVc.swift
class BarkVc: UIViewController {
    private let CableUrl = "ws://devnokiyo.example.com/cable" // ActionCableのコネクションのエンドポイント
    private let ChannelIdentifier = "RoomChannel"             // チャンネル名

    private var client: ActionCableClient!                    // ActionCableのコネクションに関連するインスタンス
    private var channel: Channel!                             // ActionCableのチャンネルを関連するインスタンス

    // 【補足】「ユーザーをRDBから取得して、その分だけ動的に表示して・・・」と
    // 要件が大きくなるとサンプルの目的が反れるので、3ユーザーのみに限定して作成しました。
    @IBOutlet weak var chiyoUsv: UserStatusView!  // 上段ユーザーのステータス
    @IBOutlet weak var eruUsv: UserStatusView!    // 中段ユーザーのステータス
    @IBOutlet weak var otomeUsv: UserStatusView!  // 下段ユーザーのステータス

    override func viewDidLoad() {
        super.viewDidLoad()
        // ActionCable関連のインスタンスを初期化・接続する。
        initClient()
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        // 画面を閉じるときはコネクションを切断する。
        // サンプルではアプリの中断は考慮しない。
        client.disconnect()
    }

    @IBAction func tapBawBawButton(_ sender: Any) {
        // 「ワンワン」ボタン押下時はRoomChannelのbarkアクションを呼出す。
        // 送信情報) "content":"bawbaw"
        bark(bark: "bawbaw")
    }

    @IBAction func tapWaooonButton(_ sender: Any) {
        // 「ワオーーン」ボタン押下時はRoomChannelのbarkアクションを呼出す。
        // 送信情報) "content":"waooon"
        bark(bark: "waooon")
    }

    @IBAction func tapMumblingButton(_ sender: Any) {
        // 「独り言」ボタン押下時はRoomChannelのmumblingアクションを呼出す。
        // 送信情報) 無し。アクションを呼出すのみ。
        channel?.action("mumbling")
    }

    private func initClient() {
        // コネクションのエンドポイントを指定する。
        // 送信情報) 呼出し元ViewControllerから取得したaccount
        // 【補足】ユーザーの割出しと認証が必要ならOpenID Connectのアクセストークンなどになると思います。
        client = ActionCableClient(url: URL(string: "\(CableUrl)/?account=\(account!)")!)
        client.onConnected = {
            // 【補足】このサンプルではチャンネルをサブスクライブした直後にRoomChannelのgreetingアクションを呼出します。
            // コネクションの接続完了を待たずにチャンネルを作成してしまうと非同期の問題でRoomChannelのgreetingアクションが
            // 呼ばれないことがありました。そのため、コネクションの接続が完了してからチャンネルをサブスクライブします。
            self.initChannel()
        }

        // 【補足】onConnectedの他にも以下のコールバックが用意されています。
        // client.willConnect = {}
        // client.onDisconnected = { (error: ConnectionError?) in }
        // client.willReconnect {}

        // 【補足】以下のプロパティで再接続のポリシーを設定出来るようです。(厳密に確認していません。)
        // client.reconnectionStrategy

        // コネクションに接続する。
        client.connect()
    }

    private func initChannel() {
        // チャンネルを作成する。サブスクライブは手動で行う。
        // 送信情報) "room":ルーム名(ID)
        self.channel = self.client.create(ChannelIdentifier, identifier: ["room": room], autoSubscribe: false)

        self.channel.onSubscribed = {
            // サブスクライブしたら、ルームに入ったことになる。
            // 同じルームのアクティブユーザーに通知するのでRoomChannelのgreetingアクションを呼出す。
            self.channel?.action("greeting")
        }

        self.channel.onReceive = {(data: Any?, error: Error?) in
            // 自他問わずアクティブユーザーより送信された情報をこのコールバックで受信する。
            if let response = RoomChannelResponse(data: data) {
                // 誰に関する情報か判定する。自身も含まれる。
                // findUserStatusViewメソッドは以下のいずれかのクラス変数を返却する。
                // chiyoUsv
                // eruUsv
                // otomeUsv
                // 受信情報) "account":ユーザーのアカウント
                let userStatusView = self.findUserStatusView(account: response.account)
                switch response.type {
                case .roomIn:
                    // 受信情報) "type":"in"
                    // 受信情報) "roommate":"[アクティブユーザーのアカウント...]"
                    // updateUserStatusメソッドはアクティブユーザーを以下のようにする。
                    // 表示内容:(^○^)
                    // オンラインの色(緑)
                    self.updateUserStatus(accounts: response.roommate, type: response.type)

                    // ルームに入ったユーザーは挨拶する。
                    // 受信情報) "type":"in"            表示内容: (^○^) (言語:日本語)
                    userStatusView.bark.text = NSLocalizedString(response.type.rawValue, comment: self.defaultComment)
                    break
                case .roomOut:
                    // ルームから出たユーザーは挨拶する。
                    // 受信情報) "type":"out"           表示内容: ( ˘ω˘ ) (言語:日本語)
                    userStatusView.bark.text = NSLocalizedString(response.type.rawValue, comment: self.defaultComment)
                    // オフラインの色(赤)に変更する。 
                    userStatusView.online.backgroundColor = UIColor.red
                    break
                case .mumbling:
                    // 受信情報)
                    //  "type":"mumbling"
                    //  "content":"(゚Д゚;)"             表示内容: (゚Д゚;) (言語:不問) バックエンドの固定値なので言語設定に依存しない
                    // 【補足】「独り言」は自身が送信した情報をActionCableを経由して自身のみが受信します。
                    if let content = response.content {
                        userStatusView.bark.text = content
                    }
                    break
                case .bark:
                    // 受信情報)
                    //  "type":"bark"
                    //  "content":"bawbaw" / "wooon"  表示内容: ワンワン / ワオーーン (言語:日本語)
                    if let content = response.content {
                        userStatusView.bark.text = NSLocalizedString(content, comment: self.defaultComment)
                    }
                    break
                }
            }
        }

        // チャンネルをサブスクライブする。
        self.channel.subscribe()
    }

    private func bark(bark: String) {
        // 「ワオーーン」ボタン押下時はRoomChannelのbarkアクションを呼出す。
        // 送信情報) "content":bark
        channel?.action("bark", with: ["content": bark])            
    }

筆者がハマったところ

  • ActionCableClientのビルドエラー
    前述のとおり修正しました。

  • チャンネルの初期化
    コネクションに接続した直後にチャンネルのアクションへ送信するとき、送信できないときがありました。チャンネルの初期化はコネクションの接続が完了してから行います。非同期処理のタイミングによる問題だと思います。

    失敗例
    client.connect()
    channel = self.client.create(ChannelIdentifier, identifier: ["room": room], autoSubscribe: false)
    channel.subscribe()
    
    成功例
    client.onConnected = {
        channel = self.client.create(ChannelIdentifier, identifier: ["room": room], autoSubscribe: false)
        channel.subscribe()
    }
    client.connect()
    

参考

Rails 5 Action CableチャットアプリのiOSクライアント側を作る

終わりに

チャットアプリのサンプルが定番なので、少し違うアプローチでサンプルを作っていたはずなのですが、結局仕組みは似たり寄ったりになってきました。筆者はiOS、Androidの順で実装しているのでバックエンドとの主な仕様調整はiOS版で行なっています。その意味ではiOS版のほうが壁に当たることが多いです。細かい作込みはしていませんが、バックエンドとアプリの双方を実装して、やりたいことの表現と仕組みを概ね理解することができました。

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

Androidエミュレーターのhostsを書換えてローカルサーバーへ接続する

前置き

小ネタになります:pushpin:
macOSにローカルサーバー立ててiOSシミュレーターとAndroidエミュレーターからアクセスする開発環境を構築しています。iOSシミュレーターはmacOSのhostsを認識しているようでしたが、Andoridエミュレーターでは別途hostsを設定する必要がありました。開発環境はmacOSを想定しています。

adb/emulatorのパスを通す

必須ではありませんがコンソールの可読性も上がりますのでパスを通しておきます。

  1. .bash_profileを編集

    $ vi ~/.bash_profile
    
  2. パスを追加
    AndroidStudio一式をデフォルトでインストールした場合、以下のパスになっていると思います。

    .bash_profile
    export PATH="$PATH:$HOME/Library/Android/sdk/platform-tools"
    export PATH="$PATH:$HOME/Library/Android/sdk/emulator"
    
  3. 再読込み

    $ source ~/.bash_profile
    

エミュレーターを設定する

  1. 対象エミュレーターを選定

    $ emulator -list-avds
    Pixel_2_API_28
    
  2. 書込み可能状態でエミュレーターを起動

    $ emulator -avd Pixel_2_API_28 -writable-system
    emulator: WARNING: System image is writable
    emulator: INFO: boot completed
    
  3. rootを取得
    前述の「書込み可能状態でエミュレーターを起動」を行うとコマンドの結果がフォアグラウンドになるので、以降のコマンドはもう1つコンソールを立ち上げて実行します。(バックグランド実行でも良いですね。)

    $ adb root
    restarting adbd as root
    
  4. /systemを書込み可能にして再マウント

    $ adb remount
    remount succeeded
    
  5. hostsに書込む
    AndroidシミュレーターからmacOSのローカルホストへ接続するにはIPアドレスに10.0.2.2を指定します。127.0.0.1ではありません。ご注意ください:construction:

    $ adb shell "echo 10.0.2.2 devnokiyo.example.com >> /system/etc/hosts"
    

    なお、上記の例では追記しか出来ないので、一度AndroidエミュレーターのhostsファイルをmacOSで編集してから、Androidエミュレーターへ配置する方法も有用だと思います。

    1. Androidシミュレーターのhostsを取得

      $ adb pull /system/etc/hosts .
      
    2. hostsを編集

    3. hostsをAndroidシミュレーターへ配置

      $ adb push .hosts /system/etc/hosts
      

    この時点でChromeを起動または再起動すると反映されていることが確認できます:clap::clap::clap:
    02.png

  6. 非rootに変更

    $ adb unroot
    restarting adbd as non root
    
  7. 再起動

    $ adb reboot
    

トラブルシューティング

API28の他、API27でも試してみましたが、筆者の環境では不安定になることがあります。
「書込み可能状態でエミュレーターを起動」を行うとコマンドの結果が、途中から以下のように表示される続けることがあります。

WARNING: AsyncSocketServer.cpp:99: Error when accepting host connectionError message: Bad file descriptor
    :
    :

完全な解決策がわかっていないのですが、AndroidStudioやAndroidエミュレーターを再起動すると解決します。「解決」というより仕切り直しの意味合いが多いですね。

参考

Android Emulatorのhostsを書き換える方法
Android端末のhostsを書き換える方法
Androidのエミュレーターから自身のPC(localhost)へ接続

終わりに

筆者はiOS、Androidの順で開発を進めており先入観が2つありました。

  • AndroidエミュレーターにもmacOSのhostsが適用されているはず
    → されてませんでした。
  • hostsにローカルIP(ループバック)127.0.0.1を追加する
    10.0.0.22でした。

ご参考になれば幸いです。

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

NotificationCenterでの複数の通知をコールバックで待ちあわせる

はじめに

NotificationCenterでの複数の通知を待ちたいユースケースの実装例です。
複数の通知を待ち合わせ、すべての通知を受信した場合にコールバックを実行します。

使用例

NotificationWaiter.wait(for .someNotification) {
    // .someNotificationの通知を受信した時
}

NotificationWaiter.wait(for [.fooNotification, .barNotification]) {
    // .fooNotification, .barNotificationの通知をすべて受信した時
}

サンプルコード

/// NotificationCenterでの通知を1度だけコールバックで待つためのクラス
public enum NotificationWaiter {
    /// 引数で指定したNotification.Nameが通知されるまで待ち、通知されたタイミングでcompletionを実行する
    ///
    /// - Parameters:
    ///   - name: 対象となる通知名
    ///   - notificationCenter: 通知センター
    ///   - completion: 通知を受信した際に実行するクロージャー
    static func wait(for name: Notification.Name,
                     notificationCenter: NotificationCenter = NotificationCenter.default,
                     completion: @escaping () -> Void) {
        var token: NSObjectProtocol?
        token = notificationCenter.addObserver(forName: name,
                                               object: nil,
                                               queue: nil) { _ in
                                                guard let token = token else { return }
                                                notificationCenter.removeObserver(token)
                                                completion()
        }
    }

    /// 引数で指定したNotification.Nameが全て通知されるまで待ち、全て通知されたタイミングでcompletionを実行する
    ///
    /// - Parameters:
    ///   - names: 対象となる通知名の配列
    ///   - notificationCenter: 通知センター
    ///   - completion: 全ての通知を受信した際に実行するクロージャー
    static func wait(for names: [Notification.Name],
                     notificationCenter: NotificationCenter = NotificationCenter.default,
                     completion: @escaping () -> Void) {
        let dispatchGroup = DispatchGroup()
        let dispatchQueue = DispatchQueue(label: "", attributes: .concurrent)

        names.forEach { name in
            dispatchGroup.enter()
            dispatchQueue.async(group: dispatchGroup) {
                wait(for: name) {
                    dispatchGroup.leave()
                }
            }
        }

        dispatchGroup.notify(queue: .main) {
            completion()
        }
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む