- 投稿日:2019-03-18T23:16:43+09:00
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.swiftpublic protocol APIType { associatedtype ResponseType var request: Request { get } var responseSerializer: ResponseSerializer { get } }また返すmodelの型のタイプをResponseTypeとして保持している。
Request
APITypeのpropertyの内、HTTPリクエストを送る際に必要な情報を保持。SwiftのURLRequestのAPIリクエストに必要な情報のみを抜粋したようなもの。
Request.swiftpublic 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.swiftpublic 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.swiftimport 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.swiftpublic 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.swiftpublic 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]>.serializeSerializableを使ったSerializerの作成例
UsersSerializer.swiftstruct 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.swiftpublic 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.swiftpublic protocol RequestDispatchable { func dispatch(request: Request, onSuccess: @escaping (Data) -> Void, onError: @escaping (Error) -> Void) }APIError
エラーは本記事の本質からはずれるのでかなり簡略に書いている。実際にプロジェクトで使う場合は必要なcase等を追加。
APIError.swiftpublic enum APIError: Swift.Error { case urlNotValid case noResponseData case responseParseFailed }4. Request Dispatcher
Request DispatcherにはRequestDispatchableに準拠した複数のdispatcherは定義されている。
ここでは3つ方法での実装を解説するが、実際のプロジェクトではおそらく全ては必要にならない。URLSessionRequestDispatcher
ライブラリ等に頼らずURLSessionを使ってHTTPリクエストを実装するクラス。
URLSessionRequestDispatcher.swiftpublic 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.swiftimport 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.swiftpublic 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.swiftstruct User: Codable { let id: Int let name: String }BaseAPI
base URLの共通化とenumを使った実際のAPIクラスでselfでのswitch分を書きやすくするための処理。
実際に作成するAPIへはこのprotocolを準拠させる。APITypeのWrapper。BaseAPI.swiftimport 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.swiftimport 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.swiftimport 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 // エラー処理 })参考文献
- 投稿日:2019-03-18T17:38:59+09:00
[swift][iOS]RxTableViewに紐付く画像をlazy loadingする
TableViewに紐付く形でUIImageViewを設置
こんな感じで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が行われることはなくなった。
- 投稿日:2019-03-18T14:07:08+09:00
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と連結されるので/
から始める点に注意。
- 投稿日:2019-03-18T13:39:39+09:00
WKWebViewでBasic認証を実装する
WKWebViewでBasic認証を実装する
WKWebViewでBasic認証を実装するサンプルコードです。
- 環境
- Xcode10.1
最終的なサンプルを見たいならこちら
WebViewの生成
WKWebViewを生成しdelegateの設定を行います。
認証に関するdelegateはnavigationDelegate
です。(WKWebViewにはuiDelegate
もあるので気をつけて。)以下はコードで生成する例です(Storyboardでも問題なし)。
ViewController.swiftimport 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.swiftextension 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.swiftextension 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.swiftimport 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) } } }
- 投稿日:2019-03-18T12:50:42+09:00
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>
でファイルを複数選択した後に、再度ファイルを再選択・キャンセルすると下記画像のようなエラーが発生するというものです。
問題が発生したため、このWebページが再読み込みされました。
というメッセージが表示される
"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>CSSinput[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.
問題点
- 常に「ファイル未選択」と表示される → 選択したファイル数を「ファイル未選択」の文言の上に無理やり被せる?
- 根本的な解決方法ではない気がする → もっと良い解決方法をご存知の方がいらっしゃったらご教示ください
- 投稿日:2019-03-18T12:45:55+09:00
【VisualStudio】新規プロジェクト製作時、ターゲットプラットフォームが選択できないときの対処法
- 投稿日:2019-03-18T12:30:51+09:00
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
Flutterの主な違い:すべてのピクセルを所有する
https://medium.com/flutter-community/flutters-key-difference-owning-every-pixel-e2135b44c8a
Tom Gilderが、 Flutterが画面をどのように使用し、他のフレームワークと比較するかをレビューします。Flutter TikTokのUIを構築する - パート2:小さな部品を構築する
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コードジェネレータ
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ログを非表示にしてカスタマイズする機能
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)を使用したコンピュータビジョン
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
さまざまなアニメーションウィジェットで遊べるアプリです。
- 投稿日:2019-03-18T01:27:23+09:00
スマート家電コントローラ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秒おきに点滅します。私はつまようじで長押ししました。
- 投稿日:2019-03-18T01:14:39+09:00
Nginx,Redis,MySQLを使ってほんの少し実践的なRails ActionCableと、iOS/Androidのサンプルアプリを作って全体像を学ぶ〜Android編〜
前置き
「Rails ActionCableで双方向通信してみたい」「モバイルアプリでリアルタイム通信アプリ作りたい」と思いサンプルアプリを作ってみました。個々の詳細については既に解説してくださっている記事はありますので、大まかに環境構築やソースコードを記事にします。以下の3部構成になっています。
お遊びサンプルの紹介
以下のアニメーションGIF(ちょっと荒いですね
)をご覧ください。各ユーザーのアクティブ状況を表示して、メッセージをやりとります。もっと砕けた表現をするならば、筆者の愛犬たちが寝起きして、鳴いたり、遠吠えしたり、唸ったりします。
このアプリでは大きく2つのActionCableの使い方があります。
- 同じルーム内の全ユーザーにブロードキャスト
- ルームに入る
現在ルーム内にいるユーザー(以下、アクティブユーザー)にルームに入ったことを通知し、アクティブユーザーを取得します。 そして、各ユーザーのアクティブ状況を表示します。- 「ワンワン」ボタンと「ワオーーン」ボタン
文字入力で任意の文字が送れないだけで、チャットでいうところの「メッセージ」とほぼ同義です。ボタンに対応したメッセージを送信します。
※アニメーションGIFでは「わんわん」「ワオォーン」になっています。途中から文字を修正しました![]()
- ルームから出る
「←」をタップしたり、アプリを閉じたりするとルームから出たことにします。ルームに入るときと同様にアクティブユーザーを取得して、各ユーザーのアクティブ状況を表示します。- 自分にブロードキャスト
- 「独り言」
「独り言」ということで自分のみメッセージを受信します。構成
名前 バージョン macOS Mojave 10.14.3 AndroidStudio 3.3.2 Kotlin 1.3.21 AVD(API) Pixel(28), Pixel2(27 / 28)
hosts(AVD)
よろしければ筆者の記事をご覧ください。筆者はこのサンプルアプリを作る過程でAVDのhostsについて知り記事にしました。hosts10.0.2.2 devnokiyo.example.comソースコードはGitHubに公開しています。よろしければご覧ください。
actioncable-client-javaを導入する
ライブラリを利用して開発しますのでgradleに追記してAndroidStudioで同期します。
ソースコードの説明
サンプルアプリはActionCableと本質的に関係ない部分も多いので、GitHubに公開しているソースコードの原形を保ちながら主要な部分を抽出しました。ソースコードの中にコメントで説明します。
app/src/main/java/com/devnokiyo/actioncableapp/activities/BarkActivity.ktclass 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版同様に細かい作込みはしていませんが、バックエンドとアプリの双方を実装して、やりたいことの表現と仕組みを概ね理解することができました。
- 投稿日:2019-03-18T01:13:45+09:00
Nginx,Redis,MySQLを使ってほんの少し実践的なRails ActionCableと、iOS/Androidのサンプルアプリを作って全体像を学ぶ〜iOS編〜
前置き
「Rails ActionCableで双方向通信してみたい」「モバイルアプリでリアルタイム通信アプリ作りたい」と思いサンプルアプリを作ってみました。個々の詳細については既に解説してくださっている記事はありますので、大まかに環境構築やソースコードを記事にします。以下の3部構成になっています。
お遊びサンプルの紹介
以下のアニメーションGIFをご覧ください。各ユーザーのアクティブ状況を表示して、メッセージをやりとります。もっと砕けた表現をするならば、筆者の愛犬たちが寝起きして、鳴いたり、遠吠えしたり、唸ったりします。
このアプリでは大きく2つのActionCableの使い方があります。
- 同じルーム内の全ユーザーにブロードキャスト
- ルームに入る
現在ルーム内にいるユーザー(以下、アクティブユーザー)にルームに入ったことを通知し、アクティブユーザーを取得します。そして、各ユーザーのアクティブ状況を表示します。- 「ワンワン」ボタンと「ワオーーン」ボタン
文字入力で任意の文字が送れないだけで、チャットでいうところの「メッセージ」とほぼ同義です。ボタンに対応したメッセージを送信します。
※アニメーションGIFでは「ワオーン」とか「ワオォーン」になっています。途中から文字を修正しました![]()
- ルームから出る
「キャンセル」をタップしたり、アプリを閉じたりするとルームから出たことにします。ルームに入るときと同様にアクティブユーザーを取得して、各ユーザーのアクティブ状況を表示します。- 自分にブロードキャスト
- 「独り言」
「独り言」ということで自分のみメッセージを受信します。構成
名前 バージョン macOS Mojave 10.14.3 Xcode 10.1 Swift 4.2.1 iOS 12.1 シミュレーター iPhone 6 / 7 / 8
hosts(macOS)
hosts127.0.0.1 devnokiyo.example.comソースコードはGitHubに公開しています。よろしければご覧ください。
ActionCableClientを導入する
ライブラリを利用して開発します。少し変則的な導入をしているので説明します。
まず通常どおりCocoaPodsで導入する
公式の説明どおり一般的な導入をまず行います。
Podfilepod "ActionCableClient"$ pod installビルドエラーを解消する
残念ながら現行バージョン(0.2.3)ではSwift4.2に対応しきれていないようで、筆者の環境ではビルドエラーが発生してしまいます。また、開発中と思われる最新のコードはビルドは通りますが動作が不安定です。(バージョンのタグ付けがないので不安定なのは当たり前ですね。)
同件と思われるissueに便乗しつつ、今回はビルドエラーの箇所を修正することにしました。
- Pods/ActionCableClient/Source/Classes/RetryHandler.swift
![]()
- noshilan/Pods/Starscream/Source/WebSocket.swift
![]()
今回はこの修正がありますのでCocoaPodsでインストールしたライブラリもGitの管理対象に含めました。
ソースコードの説明
サンプルアプリはActionCableと本質的に関係ない部分も多いので、GitHubに公開しているソースコードの原形を保ちながら主要な部分を抽出しました。ソースコードの中にコメントで説明します。
ActionCableApp/ViewControllers/BarkVc.swiftclass 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版のほうが壁に当たることが多いです。細かい作込みはしていませんが、バックエンドとアプリの双方を実装して、やりたいことの表現と仕組みを概ね理解することができました。
- 投稿日:2019-03-18T00:24:23+09:00
Androidエミュレーターのhostsを書換えてローカルサーバーへ接続する
前置き
小ネタになります
macOSにローカルサーバー立ててiOSシミュレーターとAndroidエミュレーターからアクセスする開発環境を構築しています。iOSシミュレーターはmacOSのhostsを認識しているようでしたが、Andoridエミュレーターでは別途hostsを設定する必要がありました。開発環境はmacOSを想定しています。adb/emulatorのパスを通す
必須ではありませんがコンソールの可読性も上がりますのでパスを通しておきます。
.bash_profileを編集
$ vi ~/.bash_profile
パスを追加
AndroidStudio一式をデフォルトでインストールした場合、以下のパスになっていると思います。.bash_profileexport PATH="$PATH:$HOME/Library/Android/sdk/platform-tools" export PATH="$PATH:$HOME/Library/Android/sdk/emulator"再読込み
$ source ~/.bash_profileエミュレーターを設定する
対象エミュレーターを選定
$ emulator -list-avds Pixel_2_API_28書込み可能状態でエミュレーターを起動
$ emulator -avd Pixel_2_API_28 -writable-system emulator: WARNING: System image is writable emulator: INFO: boot completedrootを取得
前述の「書込み可能状態でエミュレーターを起動」を行うとコマンドの結果がフォアグラウンドになるので、以降のコマンドはもう1つコンソールを立ち上げて実行します。(バックグランド実行でも良いですね。)$ adb root restarting adbd as root
/systemを書込み可能にして再マウント
$ adb remount remount succeeded
hostsに書込む
AndroidシミュレーターからmacOSのローカルホストへ接続するにはIPアドレスに10.0.2.2
を指定します。127.0.0.1
ではありません。ご注意ください$ adb shell "echo 10.0.2.2 devnokiyo.example.com >> /system/etc/hosts"なお、上記の例では追記しか出来ないので、一度AndroidエミュレーターのhostsファイルをmacOSで編集してから、Androidエミュレーターへ配置する方法も有用だと思います。
Androidシミュレーターのhostsを取得
$ adb pull /system/etc/hosts .hostsを編集
hostsをAndroidシミュレーターへ配置
$ adb push .hosts /system/etc/hosts非rootに変更
$ adb unroot restarting adbd as non root
再起動
$ 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
でした。ご参考になれば幸いです。
- 投稿日:2019-03-18T00:07:03+09:00
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() } } }