20190324のiOSに関する記事は5件です。

Flutterウィークリー #51

Flutterウィークリーとは?

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

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

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

読み物&チュートリアル

デスクトップFlutterアプリ

https://blog.usejournal.com/desktop-flutter-apps-8602dac54f68


Stefan Matthias AustがFlutterデスクトップアプリケーションの作成に関する文書を作成しました

Flutter使って次のiOSおよびAndroidアプリを構築しましょう

https://blog.google/technology/developers/build-your-next-ios-and-android-app-flutter/?linkId=65160155


FlutterについてのGoogleブログのMartin Aguinisによる記事と、なぜそれが大好きなのか。

Flutter単体テスト:httpリクエスト

https://medium.com/@danaya/unit-testing-in-flutter-http-requests-bca215569656


David AnayaがテストでHTTPリクエストをモックする方法を教えてくれます

Flutter - BackdropFilter

https://medium.com/@aseemwangoo/flutter-backdropfilter-8f3d6958f635


Aseem WangooがFlutter画像をぼかす方法を紹介します。

Flutter Todosチュートリアル "flutter_bloc"

https://medium.com/flutter-community/flutter-todos-tutorial-with-flutter-bloc-d9dd833f9df3


BLoCを使用してTodoアプリを作成する方法に関するFelix Angelovによるチュートリアル

React NativeまたはFlutter :どちらを選ぶべきですか? (パート2)

https://medium.com/flutter-community/react-native-or-flutter-which-should-i-choose-part-two-3950ac273492


Rap Payneによる記事の第2部で、 FlutterとReact Native比較しています。

フラッタでのモバイルデータベースの移行(sqlite)

https://medium.com/@efthymis/migrating-a-mobile-database-in-flutter-sqlite-44ac618e4897


データベースの移行は常に頭痛の種です、Efthymis Sarmpanisは従うべきステップとそれをどう扱うかを分析します。

複雑なFlutterウィジェットのためのAPIデザインのヒント

https://medium.com/flutter-io/api-design-tip-for-complex-flutter-widgets-7794e9c7144c?linkId=65005691


Amir Hardonは、ウィジェットを作成するときの2つのアプローチについて説明します。

Flareを使ったFlutterより良いアニメーション - 実験

https://medium.com/filledstacks/better-animations-in-flutter-using-flare-an-experiment-ddcb35ab0650


Dane MackierはFlareアニメーションを実験していて、それがどのようになったのかを教えてくれます。

Flutterドライバを使用したFlutter UIのテスト

https://medium.com/flutter-community/testing-flutter-ui-with-flutter-driver-c1583681e337


Darshan Kawarが、 Flutter機器テストの実行方法を順を追って説明します。

Flutter :ウィジェット間のコミュニケーション

https://medium.com/flutter-community/flutter-communication-between-widgets-f5590230df1e


Diego Velasquezが、いくつかのウィジェット間で情報を送信するためのさまざまなアプローチについて説明します。

Flutter firebase crashlyticsに公式サポートを追加

https://medium.com/@guitcastro/flutter-add-official-support-to-firebase-crashlytics-198e9deeae1d


Flutter用Firebase crashlyticsプラグインがついに登場しました!

Flutter Deep Dive、パート2:「RenderFlexの子供たちはゼロ以外のフレックスを持っています…」

https://medium.com/flutter-community/flutter-deep-dive-part-2-renderflex-children-have-non-zero-flex-230dd2a970c7


Scott Stollが、 Flutter最も厄介なエラーメッセージについて説明し、それを詳細に説明します。

DartをUIのためのより良い言語にする

https://medium.com/dartlang/making-dart-a-better-language-for-ui-f1ccaf9f546c


DartチームのBob Nystromが、 Dart 2.3で見つけたDart新機能について説明します。

ツールボックス - Flutterチャート

https://fluttersensei.com/posts/the-toobox-charts-for-flutter/


Flutter Senseiが、プロジェクトでchart_flutterライブラリを使用する方法を紹介します。

ビデオ&メディア

Flutterチュートリアル - Flutter FutureBuilder(とJSONData)

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


このビデオでは、FutureおよびFutureBuilderウィジェットを使用して、フラッタアプリケーションでJSONデータを処理する方法を説明します。

Flutter - スクロール効果

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


Raja Yoganが、あなたのアプリの見栄えを良くするためのスクロール効果について説明します。

却下可能(今週のFlutterウィジェット)

https://www.youtube.com/watch?v=iEMgjrfuc58&list=PLjxrf2q8roU23XGwz3Km7sQZFTdB996iG&t=0s&index=29


Dismissibleウィジェットを使用すると、左または右にスワイプしてリスト項目を消去できます。

Hacker Newsアプリを暗黙のうちにアニメーション化する(The Boring Flutter Development Show、Ep。18)

https://www.youtube.com/watch?v=8ehlWchLVlQ&index=28&list=PLjxrf2q8roU3ahJVrSgAnPjzkpGmL9Czl&linkId=65112315


今週のボーリングショーで、AndrewとFilipが、暗黙のアニメーションと明示的なアニメーションの違いについて説明します。

Flutter + Firebaseを使った、反射的なアニメーションのスライドショー

https://www.youtube.com/watch?v=8PfiY0U_PBI&feature=youtu.be&app=desktop


PageViewウィジェットを使用してFlutterアニメーションスライドショーを作成し、それをCloud Firestoreでフィルタリングできるようにします。

Flutter - Layout Builderを使用してモバイルおよびタブレット用のレスポンシブレイアウトを作成する方法

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


このチュートリアルでは、 Flutter Layout Builderウィジェットを使用してレスポンシブレイアウトを作成する方法を説明します。

#ProjectCampus - 最初からFlutterソーシャルネットワークアプリケーションを構築する

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


私たちはFlutterソーシャルネットワークを構築しています。一から。カメラの前でライブ。 Flutter用の独自のアプリを作成する方法を学ぶべきだと私たちは考えています。

FlutterでFlutterを使用する - Flutter In Focus

https://www.youtube.com/watch?v=RA-vLF_vnng&list=PLjxrf2q8roU2HdJQDjJzOeO6J3FoFLWr2&index=14&linkId=65054072


Emily FortunaがWebViewウィジェットの使い方を紹介します。

FirebaseのCloud Firestoreを使用したFlutterページネーション

https://www.youtube.com/watch?v=coR4Y-DkrLc


このビデオでは、 Flutter無限スクロールリストを実装する方法を学びます。

ライブラリ&コード

ピンイン/フラグメント

https://github.com/pinyin/fragment

StatefulWidgetとそのサブツリーで不要なbuild()呼び出しを簡単に防ぐことができます。

synw / sqlcool

https://github.com/synw/sqlcool

Sqflite用のデータベースヘルパーライブラリ

itzmeanjan / astroZ

https://github.com/itzmeanjan/astroZ

Androidアプリは、 Flutterを使用して構築された今日の天文写真を表示しています:)

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

Vuzix Bladeで開発してみた(QRコード読取編)

はじめに

初めましてApv(@apv23973)です。Qiitaに始めて投稿します。
Androidの開発は色々やった事があるのですが、
この度、スマートグラスに興味があってこのVuzix Blade開発にチャレンジしようと思いました。

まずSmartGlassと言えば数年前にGoogleが発売したGoogle Glassではないでしょうか?
あまり万人受けしなかった気もしますが、ここ数年、商業用を中心に盛り上がりを見せ始めている産業かと思います。

そんな中、私が大変興味を持ったのがVuzix社が出したVuzixBlade SmartGlassです。
出来る限り情報を更新していければと思います。

Vuzix Bladeのはじめかた

初めに、VuzixBladeに関して大変参考にさせて頂いたのが
drama(@1901drama)さんが書いたこの記事になります。
なので私の記事はこのはじめかたが終了したと仮定して書きます。

Vuzix Blade(ARスマートグラス)のはじめかた
Vuzix Blade(ARスマートグラス)のはじめかた その2

QRコード読取について

私が前に開発していたのはEclipseだったのでAndroid Stdioになってこんなに楽なったのかと実感してしまいました。
世間でQRコードの読取が有名なのは、Google が開発して公開している、様々な一次元や二次元のバーコードの生成/操作ができるオープンソースライブラリのZxingだと思います。
なので今回もこのZxingで実装していきます。

実装

1. Zxingライブラリーの追加

build.gradle
dependencies {
    implementation 'com.vuzix:hud-actionmenu:1.1'
    implementation 'com.vuzix:hud-resources:1.1'
    implementation 'com.journeyapps:zxing-android-embedded:3.6.0@aar'
    implementation 'com.google.zxing:core:3.2.1'
}

こんな感じになります。

2. カメラ起動用にpermissionを追加

AndoridManifest.xml
<uses-permission android:name="android.permission.CAMERA" />

これをapplicationタグ外に宣言します。

3.画面の作成

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/hud_transparent"
    tools:context=".MainActivity">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom"
        android:gravity="bottom">

        <Button
            android:id="@+id/button"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="読み取り開始"
            android:textColor="@color/hud_transparent"
            />

    </LinearLayout>
</LinearLayout>

今回はシンプルに読取開始のボタンを1つ追加しただけになります。

4.MainActivityの編集

MainActivity.xml
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // ボタン割り当て
        Button btn = findViewById(R.id.button);
        Listener listener = new Listener();
        btn.setOnClickListener(listener);
    }

onCreateはシンプルにボタンリスナーを追加しているだけです。

MainActivity.xml
    private class Listener implements View.OnClickListener {
        @Override
        public void onClick(View view) {
            // カメラ起動
            scanBarcode(view);
        }
    }

    public void scanBarcode(View view) {
        IntentIntegrator integrator = new IntentIntegrator(this);
        integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE);
        integrator.setPrompt("QRコードを読取って下さい。");
        integrator.setTimeout(10000);
        integrator.setCameraId(0); // Use a specific camera of the device
        integrator.initiateScan();
    }

読み取り開始ボタンを押した時にカメラを起動して読取を開始します。
integrator.setTimeout(10000);
はカメラのタイムアウトを指定します。
10000msなので10秒で自動的にキャンセル扱いになります。

integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE);
の設定によって何を読取るのか指定ができます。
中身を見ると1次元コード/2次元コードが指定できます。
指定した方が読取がよくなると聞いたことがあります。

IntentIntegrator.java
    // Product Codes
    public static final String UPC_A = "UPC_A";
    public static final String UPC_E = "UPC_E";
    public static final String EAN_8 = "EAN_8";
    public static final String EAN_13 = "EAN_13";
    public static final String RSS_14 = "RSS_14";

    // Other 1D
    public static final String CODE_39 = "CODE_39";
    public static final String CODE_93 = "CODE_93";
    public static final String CODE_128 = "CODE_128";
    public static final String ITF = "ITF";

    public static final String RSS_EXPANDED = "RSS_EXPANDED";

    // 2D
    public static final String QR_CODE = "QR_CODE";
    public static final String DATA_MATRIX = "DATA_MATRIX";
    public static final String PDF_417 = "PDF_417";

読取った後の処理

MainActivity.xml
    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        IntentResult result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data);

        if(result != null) {
            if(result.getContents() == null) {
                // 読取キャンセル
                Toast.makeText(this, "Cancelled", Toast.LENGTH_LONG).show();
            } else {
                // 読取成功
                Toast.makeText(this, "Scanned: " + result.getContents(), Toast.LENGTH_LONG).show();
            }
        } else {
            super.onActivityResult(requestCode, resultCode, data);
        }
    }

実装はここまでです。

5.実行
画面のエビデンスを張り付けていますが、
背景色の黒は全て透明化になっています。(グラスつけてるからね・・・)
device-2019-03-24-203942.png
device-2019-03-24-203903.png
QR_Code1553427699.png
device-2019-03-24-204128.png

6.おわりに
Vuzix Bladeでも基本的に通常のAndroidと同じなんだなと言う事がわかりました。
ただ、Buttonのイメージとかが想像よりちょっと違ったかも…

載せてはいませんが、ボタンを2つ配置した時はどちらが選択されているのかホント分かりません。
公式サイトにもちょこっと書いてありましたが、UIを決めるのは大事かもしれません。

次回は通信関係に手を出してみようかな・・・ 
ここまで読んで頂きありがとうございました。

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

iOSとAndroidの定期購読の期間の伸び方の謎

〇概要

iOSとAndroidはともにサブスクリプション(定期購読)の機能が実装されており、購入時に決められた期限が与えられ、有効期限が切迫すると課金継続中なら有効期限が決められた期間分追加されます。
しかし、期限の伸び方の詳細はドキュメントに公開されておらず、よくわからない伸び方をするケースがあるので、実体験からわかった謎ルールをいくつか書いてみます。
謎はあまり解明されてないので、こういうケースもあるのかということで読んでください。
よくわからないことを書いているので、報告や指摘は大歓迎です。
サービスによっては起きたり起きなかったりするかもしれません。

今回は期間を一ヶ月に設定して、特に問題が起きやすい2/28,3/1当たりに注目しています。)

〇iOS

・新規購入時の延長

購入日時 + その月の日数 - (0時間 or 1時間)
その月の日数とは1/15に購入すれば1月が31日まであるので31日が延長される。
しかしロサンゼルスとの時差17時間が影響を与えていると思われ、考慮しなければならない。
そのため3/1の16:59までは2月という考え方になり、
例えば日本で3/1の16時に購入すると、2月判定になり28日が足され、3/29日の15時までの期限となるのでユーザーからのクレーム案件になるかもしれないので注意。
(サマータイム外はロサンゼルスと16時間の時差ですが、年中17時間で固定のようです)

- (0時間 or 1時間)については日によって全ユーザーがついたり、つかなかったり。その規則性はよくわからず。

ちなみに1/28 - 1/31に購入したものは3/1の有効期限になる。

・自動更新の延長

前回日時 + その月の日数 +- (2時間)

たまに+-2時間が付くが原因不明。

購入した日付を基準に戻ろうとする作用が起こる。
(28日に購入した例)2019-01-28 21:00:00 → 2019-02-28 21:00:00 → 2019-03-28 20:00:00
(31日に購入した例)2019-01-31 21:00:00 → 2019-02-28 21:00:00 → 2019-03-31 20:00:00

〇Android

・新規購入時

購入日時 + 31日 + 2時間

3/1の購入は何時でも4/1とわかりやすい。(他社ブログでは時差が影響して3/29になるとの報告もあり)
+ 2時間は多少前後することもある。

・自動更新時

前回日時 + その月の日数

その月の日数は11時ぐらいを基準に前日と判断されることもある。時差の影響かもしれない。

iOSと違い、29,30,31日に購入すると、更新時に28日に丸められてしまうので、更新数がその日に偏るので注意。
またユーザーとしては損をするケースとなる。

(28日に購入した例)2019-01-28 15:00:00 2019-02-28 15:00:00 2019-03-28 15:00:00
(31日に購入した例)2019-01-31 15:00:00 2019-02-28 15:00:00 2019-03-28 15:00:00

そして
2019-01-31 15:00:00 → 2019-03-01 15:00:00 → 2019-03-29 15:00:00

となるケースも多くみられ、ひと月に二回決済が行われるため、集計やユーザー対応に困ることもあり。

〇総評

iOSは17時間の時差を考慮しないといけないので悩みもの。
それ以外は大分素直。

Androidは謎ルールやバグが多く、期間が伸びたり縮んだりが頻繁に起きたりして、昔から悩まされている。(最近は大分マシになったけど・・・)
1月に2回支払いが行われるのは、運営、ユーザー共に月報や家計簿にズレがでるなど困るケースが出てくるので直してほしい。。。

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

【iOS】UIButtonにON/OFFのスイッチ処理を1行で書く【Swift】

UIButtonに「on/off」のスイッチを付与したい...

けどいちいちフラグ立てるのはスマートじゃない...

それに複数のUIButtonにon/offのスイッチを持たせるとなったらアーッ

ってことで、UIButtonにon/offのスイッチを持たせたい時に、簡単一行でon/offのスイッチを実装するコードを書いたので(誰でもやってるかもしれないけど)晒したいと思います。

開発環境

  • Swift4.2
  • Xcode10.1

目的

UIButtonにON/OFFのスイッチ機能を持たせたい時に、下のように書くのはスマートじゃない。

ViewController.swift
    @IBOutlet var label: UILabel!

    var flag: Bool = false

    @IBAction func tappedButton(_ sender: UIButton) {
        if flag == false {
            //ONにする時に走らせたい処理
            label.text = "ONになったよ!"
            flag = true
        } else if flag == true {
            //OFFにする時に走らせたい処理
            label.text = "OFFになったよ!"
            flag = false
        }
    }

ボタン1個だけなら良いかもしれないが、何個ものUIButtonにスイッチ機能を持たせたい時は全部に同じコードを書く羽目になる。あと誰でも考えつきそう

UIButton.switch(on: , off: )みたいな感じで一行で書きたい(切実)

解決法:新しいクラスを作る

そんなわけで解決法を「Extensionでなんとか実装できないかなー...」と思って頑張ってみたんですがダメでした()

どうしてもUIButtonのインスタンスに状態を保持するプロパティを持たせる必要があったので、インスタンスプロパティを追加することができないExtensionでは実装は難しそうです。Extensionで出来る方法あればぜひ教えてください(懇願)

というわけでUIButtonにON/OFFのスイッチ機能を持たせるには、「UIButtonを継承した新しいクラスを作る」のが良さそうです。

今回は「switchButton」というクラスを作ってます。

ViewController.swift
import UIKit

class ViewController: UIViewController {

    @IBOutlet var label: UILabel!

    override func viewDidLoad() {
        super.viewDidLoad()

    }

    @IBAction func tappedButton(_ sender: switchButton) {
        //一行でon/offスイッチが書ける!!すごい!!
        sender.switchAction(initState: .on, onAction: {
            label.text = "ONになったよ!"
        }) {
            label.text = "OFFになったよ!"
        }

    }

}

class switchButton: UIButton {
    //on/offの状態を保持する
    private var currentState: Bool?

    func switchAction(initState: Switch, onAction: ()->Void, offAction: ()->Void) {
        //initstateの値をcurrentStateに設定
        if currentState == nil {
            currentState = initState.state
        }

        let currentSwitch:Bool = currentState!

        switch currentSwitch {
            case true:
                //ボタンをonからoffにする時にcurrentStateをoffにする→offActionを実行
                currentState = Switch.off.state
                offAction()
            case false:
                //ボタンをoffからonにする時にcurrentStateをonにする→onActionを実行
                currentState = Switch.on.state
                onAction()
            default:
                break
        }

    }

    //ボタンのon/offを直感的にするためenumで値を設定(無くてもよし)
    enum Switch {
        //stateの種類
        case on,off
        //stateにboolを振り分け
        var state: Bool {
            switch self {
            case .on:
                return true
            case .off:
                return false
            default:
                break
            }
        }

    }
}

これをそのままコピペして、Storyboardで@IBAction接続しているUIButtonのクラスにswitchButtonを指定してあげれば動きます。(あとテスト用のlabelも@IBOutlet接続してるのでそれもお忘れなく)

Simulator Screen Shot - iPhone 8 - 2019-03-24 at 13.11.45.png

そんなこんなでどのUIButtonでも、switchButtonクラスを指定してあげればいつでも一行でon/offスイッチが書けるようになりました。

他に良い方法あれば教えて頂けると嬉しいです!

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

[iOS]KeyPathを利用してRxSwiftベースのViewModelを刷新する

はじめに

RxSwiftを用いたMVVMでアプリケーションを開発する際、ViewModelが以下のような実装になりやすくはないでしょうか。

  • ①公開用の入力メソッドと、内部でsubscribeするためのRelayの2つを定義している
  • ②内部状態を公開するためのObservableと、値自体を公開するためのpropertyの2つを定義している
  • ③内部状態なのか、入力のRelayなのかが一見わかりにくい
上記の問題があるViewModel
final class SearchViewModel {
    let repositories: Observable<[Repository]>
    let error: Observable<Error>

    var repositoriesValue: [Repository] { return _repositories.value }

    private let _repositories = BehaviorRelay<[Repository]>(value: [])
    private let _search = PublishRelay<String>()
    private let disposeBag = DisposeBag()

    init() {
        let apiAciton = SearchAPIAction()

        self.repositories = _repositories.asObservable()
        self.error = apiAction.error

        apiAction.response
            .bind(to: _repositories)
            .disposed(by: disposeBag)

        _search
            .subscribe(onNext: { apiAction.execute($0) })
            .disposed(by: disposeBag)
    }

    func search(query: String) {
        _search.accept(query)
    }
}

①、②については、Swift4から利用できるようになったKeyPathを用いて解決します。
try! Swift Tokyo 2019にてKeypath入門のトークもあったので、KeyPathを用いて解決方法はちょうど良い利用事例になるのではないかなと思います。

③に関しては、定義する場所を明示することで解決しようと思います。

KeyPathを用いて実装するViewModel

UnioというOSSを利用することで、上記の問題を解決することができます。UnioとはUnidirectional Input Outputを示しており、InputからOutputまでの流れを単一方向にすることを目的としたframeworkです。まずは上記のSearchViewModelを、Unioを利用してInputとOutputの流れを単一方向にしたSearchViewStreamとして実装を置き換えます。

※Unioの内部実装については後ほど解説します。

Unioを利用した実装例

SearchViewStream.swift
protocol SearchViewStreamType: AnyObject {
    var input: Relay<SearchViewStream.Input> { get }
    var output: Relay<SearchViewStream.Output> { get }
}

final class SearchViewStream: UnioStream<SearchViewStream.Logic>: SearchViewStreamType {

    struct Input: InputType {
        let search = PublishRelay<String>()
    }

    struct Output: OutputType {
        let repositories: BehaviorRelay<[Repository]>
        let error: Observable<Error>
    }

    struct State: StateType {
        let repositories = BehaviorRelay<[Repository]>(value: [])
    }

    struct Extra: ExtraType {
        let apiAction = SearchAPIAction()
        let disposeBag = DisposeBag()
    }

    struct Logic: LogicType {

        func bind(from dependency: Dependency<Input, State, Extra>) -> Output {

            let apiAction = dependency.extra.apiAction
            let disposeBag = dependency.extra.disposeBag
            let state = dependency.state

            apiAction.response
                .bind(to: state.repositories)
                .disposed(by: disposeBag)

            dependency.inputObservable(for: \.search)
                .subscribe(onNext: { apiAction.execute($0) })
                .disposed(by: disposeBag)

            return Output(repositories: state.repositories, error: apiAction.error)
        }
    }

    init() {
        super.init(input: Input(), state: State(), extra: Extra(), logic: Logic())
    }
}

上記のSearchViewStreamの実装が

  • Inputに外部からの入力を定義
  • Outputに外部への出力を定義
  • Stateに内部状態を定義
  • Extraに上記以外の依存を定義
  • LogicがInput・State・ExtraをもとにOutputを生成

という区切り方で、明示的になっていることがわかるかと思います。

SearchViewStreamを実際にViewControllerで利用すると以下のようになります。

SearchViewController.swift
final class SearchViewController: UIViewController {

    let viewStream: SearchViewStreamType = SearchViewStream()
    let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        viewStream.input.accept("search-text", for: \.search)

        viewStream.output.observable(for: \.repositories)
            .subscribe()
            .disposed(by: disposeBag)

        print(viewStream.output.value(for: \.repositories))
    }
}

まず、viewStreamで公開されているpropertyは、以下のようにinputoutputのみになります。そして、それらはRelay型でラップされています。

スクリーンショット 2019-03-23 20.56.56.png

inputは、KeyPathで指定しているオブジェクトがPublishRelayだった場合に、acceptを実行しています。(PublishSubjectだった場合は、onEventを実行します)

input.png

そしてoutputでは、KeyPathで指定しているオブジェクトがObservableConvertibleTypeだった場合に、Observable<[Repository]>を返しています。
加えて、KeyPathで指定しているオブジェクトがBehaviorRelayだった場合は、valueとして[Repository]を返しています。(BehaviorSubjectだった場合は、throwableなメソッドでvalueを返します)

output.png

InputOutputのPropertyがinternalになっているが、単一方向は保証されているのか?

SearchViewStreamで定義されている、InputOutputを再度見てみましょう。

extension SearchViewStream {

    struct Input: InputType {
        let search = PublishRelay<String>()
    }

    struct Output: OutputType {
        let repositories: BehaviorRelay<[Repository]>
        let error: Observable<Error>
    }

    ...
}

それぞれで保持しているPropertyはinternalになっています。一見、PublishRelayやBehaviorRelayに直接アクセスできそうで、単一方向が保証されていないように見えます。
続いて、SearchViewStreamのsuper classであるUnioStreamの定義を見てみましょう。

Unio.UnioStream.swift
open class UnioStream<Logic> where Logic : LogicType {

    public let input: Unio.Relay<Logic.Input>

    public let output: Unio.Relay<Logic.Output>

    public init(input: Logic.Input, state: Logic.State, extra: Logic.Extra, logic: Logic)
}

UnioStreamではinput: Relay<Input>output: Relay<Output>が公開されています。InputやOutputが直接公開されているのではなく、Relay<T>にラップされています。
そのため、Relay<T>とKeyPathを介してInputやOutputに定義されているPropertyの任意のメソッドにアクセスすることはできますが、InputやOutputだったりそれらが保持しているPropertyに直接アクセスすることはできません
つまり、ラップしたオブジェクトからKeyPathを利用して、InputOutputで定義されているPropertyの任意のメソッドを1階層飛び越えて呼び出すために、それらのPropertyはinternalで定義されています。

内部状態なのか、入力のRelayなのかがわかりやすくなったのか?

SearchViewStreamで、Input(入力)、State(内部状態)とExtra(その他依存)が定義されています。
定義自体が分かれているため、何を示しているものなのかはわかりやすくなりました。
それではOutputを生成する際に、それらがわかりやすい状態になっているのでしょうか?
Outputを生成しているLogicfunc bind(from dependency: Dependency<Input, State, Extra>) -> Outputを見てみます。
(※func bind(from:) -> OutputはUnioStreamが初期化される際に一度だけ呼び出されます。)

func bind(from dependency: Dependency<Input, State, Extra>) -> Output {

    let apiAction = dependency.extra.apiAction
    let disposeBag = dependency.extra.disposeBag
    let state = dependency.state

    apiAction.response
        .bind(to: state.repositories)
        .disposed(by: disposeBag)

    dependency.inputObservable(for: \.search)
        .subscribe(onNext: { apiAction.execute($0) })
        .disposed(by: disposeBag)

    return Output(repositories: state.repositories, error: apiAction.error)
}

Outputを生成する際に必要なものは、Dependency<Input, State, Extra>として引数で渡されます。

dependency.stateでアクセスができ、dependency.inputObservable(for:)からKeyPathを介してInputのPropertyからObservableを取得します。
このようにOutput生成時でも、内部状態や入力が明示的になっています。

公開用のPropertyなどを2つ定義していた部分は不要になったのか?

こちらでも触れていますが、InputとOutputはRelay<T>にラップされKeyPathを介すことでアクセスできるものが変わるようになっています。
そのため、公開用と内部用であったり、Observable用とValue用で定義を2つする必要はなくなっていて、PublishRelayやBehaviorRelayを1つずつ定義するだけで済むようになっています。

Unio内部でどのようにKeyPathを用いているのか

Unio内部の実装で、必要な部分だけを抜き出した類似コードを下記に記載しています。
そちらをもとに解説します。

まず、InputとOutputを表現するprotocolを定義します。
この2つは、様々なオブジェクトのGeneric Where Clauseで利用します。

InputとOutput
public protocol InputType {}
public protocol OutputType {}

PublishRelayとBehaviorRelayを表現するためのprotocolを定義します。
この2つは、KeyPathでpropertyにアクセスする際のGeneric Where Clauseで利用します。

PublishRelayとBehaviorRelay
public protocol PublishRelayType {
    associatedtype E
    func accept(_ element: E)
    func asObservable() -> Observable<E>
}

public protocol BehaviorRelayType: PublishRelayType {
    var value: E { get }
}

extension PublishRelay: PublishRelayType {}
extension BehaviorRelay: BehaviorRelayType {}

そして、InputとOutputをラップしているRelay型です。
Generic ArgumentはTとなっていますが、initializerがprivateとなっているため、初期化が制限されます。

public final class Relay<T> {

    private let dependency: T

    private init(dependency: T) {
        self.dependency = dependency
    }
}

RelayのGeneric Where ClauseがInputの場合、Inputが引数となるinitializerを公開します。
そして、KeyPath<Root, Value>のRootがInputでValueがPublishRelayである場合、dependency: TからKeyPathを介してacceptを実行できる実装をします。
dependencyの定義がprivateになっていても、RelayのメソッドのKeyPathのRootをGeneric ArgumentのTにすることで、階層が違っても内部では任意のpropertyの任意のメソッドなどにアクセスすることができるようになります。
ただ、それらにアクセスするためにはInputで定義しているproperty自体はinternalになっている必要があります。

TがInputTypeの場合
extension Relay where T: InputType {

    public convenience init(_ dependency: T) {
        self.init(dependency: dependency)
    }

    public func accept<U: PublishRelayType>(_ element: U.E, for keyPath: KeyPath<T, U>) {
        return dependency[keyPath: keyPath].accept(element)
    }
}

一方で、RelayのGeneric Where ClauseがOutputの場合、Outputが引数となるinitializerを公開します。
そして、KeyPath<Root, Value>のRootがOutputでValueがPublishRelayである場合、dependency: TからKeyPathを介してObservableを取得できる実装をします。
加えて、ValueがBehaviorRelayである場合、dependency: TからKeyPathを介してvalueを取得できる実装をします。

TがOutputTypeの場合
extension Relay where T: OutputType {

    public convenience init(_ dependency: T) {
        self.init(dependency: dependency)
    }

    public func observable<U: PublishRelayType>(for keyPath: KeyPath<T, U>) -> Observable<U.E> {
        return dependency[keyPath: keyPath].asObservable()
    }

    public func value<U: BehaviorRelayType>(for keyPath: KeyPath<T, U>) -> U.E {
        return dependency[keyPath: keyPath].value
    }
}

また、Outputを生成する際に必要となるDependencyはInputをラップし、KeyPathを利用してObservableを取得できる実装にします。
なぜRelayを使わ回さないかというと、RelayでObservableが利用できる場合はGeneric ArgumentがOutputであるはずです。
つまり、性質が逆になってしまうので単一方向を保てなくなってしまうので、別な型で表現しています。

public final class Dependency<T: InputType> {

    private let input: T

    internal init(_ input: T) {
        self.input = input
    }

    public func inputObservable<U: PublishRelayType>(for keyPath: KeyPath<T, U>) -> Observable<U.E> {
        return input[keyPath: keyPath].asObservable()
    }
}

そして、UnioStream内ではInputOutputをもとに、Relay<Input>Relay<Output>を生成しています。

open class UnioStream<Input: InputType, Output: OutputType> {

    public let input: Relay<Input>
    public let output: Relay<Output>

    public init(input: Input, output: (Dependency<Input>) -> Output) {
        self.input = Relay(input)

        let dependency = Dependency(input)
        self.output = Relay(output(dependency))
    }
}

UnioStreamが公開しているRelay<Input>Relay<Output>をもとに、ViewControllerではviewStream.inputに入力だけを行い、viewStream.outputから出力の受取だけを行えるようになっています。
これらが、KeyPathを利用してInput / Outputの流れをKeyPathを利用して保証している実装となります。

※Playground上で動作確認ができるサンプルコードをGistで公開しています。

その他

ここまでに、Unioが従来のViewModelとは何が違い、どういった動作をしているのかを説明してきました。
ここでは、開発でUnioを導入するための補足情報を紹介していきます。

Xcode Templateを用いた自動生成

./Tools/install-xcode-template.shを実行することで、UnioのXcode Templateがインストールされます。
インストールが完了すると、ファイルの追加時に以下のテンプレートが選べるようになります。

Unio Componentsを選択すると、ViewControllerとViewStreamが生成されます。
生成されたViewControllerはViewStreamがPropertyとして定義された状態になっています。
そしてViewStreamは、InputOutputStateExtraLogicが定義された状態になっています。加えて、ViewStreamTypeのprotocolも定義されるため、テストターゲットでMockも簡単に作成することが可能になっています。

ezgif.com-optimize.gif

Stream内で別のStreamを利用する

ExtraにpropertyとしてStreamを定義することで、Stream内で別なStreamを利用することができます。
例として、SearchViewStreamで利用していた、SearchAPIActionをStream化します。

SearchAPIStream
protocol SearchAPIStream: AnyObject {
    var input: Relay<SearchStream.Input> { get }
    var output: Relay<SearchStream.Output> { get }
}

final class SearchAPIStream: UnioStream<SearchStream.Logic>: SearchAPIStream {
    typealias State = NoState
    typealias Extra = NoExtra

    struct Input: InputType {
        let search = PublishRelay<String>()
    }

    struct Output: OutputType {
        let repositories: Observable<[Repository]>
        let error: Observable<Error>
    }

    struct Logic: LogicType {

        func bind(from dependency: Dependency<Input, State, Extra>) -> Output {

            let response = dependency.inputObservable(for: \.search)
                .flatMapLatest { search -> Observable<Event<[Repository]>>
                    // APIアクセスの実装
                }
                .share()

            return Output(repositories: response.flatMap { $0.element.map(Observable.just) ?? .empty() }
                          error: response.flatMap { $0.error.map(Observable.just) ?? .empty() })
        }
    }

    init() {
        super.init(input: Input(), state: State(), extra: Extra(), logic: Logic())
    }
}

Inputにsearch、Outputにrepositoriesとerrorが定義されています。
SearchViewStreamでは、それらにアクセスして処理を実行するようなります。
SearchViewStreamでSearchAPIStreamを利用した場合、// before:の部分がもともとの実装との差分となります。

SearchViewStream
final class SearchViewStream: UnioStream<SearchViewStream.Logic>: SearchViewStreamType {

    struct Input: InputType {
        let search = PublishRelay<String>()
    }

    struct Output: OutputType {
        let repositories: BehaviorRelay<[Repository]>
        let error: Observable<Error>
    }

    struct State: StateType {
        let repositories = BehaviorRelay<[Repository]>(value: [])
    }

    struct Extra: ExtraType {
        let apiStream: SearchAPIStreamType
// before: let apiAction = SearchAPIAction()
        let disposeBag = DisposeBag()
    }

    struct Logic: LogicType {

        func bind(from dependency: Dependency<Input, State, Extra>) -> Output {

            let apiStream = dependency.extra.apiStream
// before:  let apiAction = dependency.extra.apiAction
            let disposeBag = dependency.extra.disposeBag
            let state = dependency.state

            apiStream.output.observable(for: \.repositories)
// before:  apiAction.response
                .bind(to: state.repositories)
                .disposed(by: disposeBag)

            dependency.inputObservable(for: \.search)
                .bind(to: apiStream.input.accept(for: \.search))
// before:      .subscribe(onNext: { apiAction.execute($0) })
                .disposed(by: disposeBag)

            return Output(repositories: state.repositories, error: apiStream.output.observable(for: \.error))
// before:  return Output(repositories: state.repositories, error: apiAction.error)
        }
    }

    init(searchAPIStream: SearchAPIStreamType = SearchAPIStream()) {
        let extra = Extra(apiStream: searchAPIStream)
        super.init(input: Input(), state: State(), extra: extra, logic: Logic())
// before: init() {
// before:    super.init(input: Input(), state: State(), extra: Extra(), logic: Logic())
    }
}

Stream内で別のStreamを利用する場合でも、input経由で入力、output経由でObservableの取得を行っていることがわかると思います。
また、SearchViewStreamのinitializerでprotocol SearchAPIStreamTypeを受け取ることで、Streamをモック化してテストを容易に行うことができるようになります。

UnioStreamのテスト方法

Streamをテストする場合、InputとOutputに注目してテストをすることになります。
SearchViewStreamを例に、テストを実装していきます。
まずSearchViewStreamは、SearchAPIStreamTypeに依存しているので、Mock化したSearchAPIStreamを定義します。

MockSearchAPIStream
final class MockSearchAPIStream: SearchAPIStreamType {
    let input: Relay<SearchAPIStream.Input>
    let output: Relay<SearchAPIStream.Output>

    let _input = SearchAPIStream.Input()

    let _outputRepositories = BehaviorRelay<[Repository]?>(value: nil)
    let _outputError = BehaviorRelay<Error?>(value: nil)

    init() {
        self.input = Relay(_input)

        let _repositories = _outputRepositories.flatMap { $0.map(Observable.just) ?? .empty() }
        let _error = _outputError.flatMap { $0.map(Observable.just) ?? .empty() }
        let _output = SearchAPIStream.Output(repositories: _repositories, error: _error)
        self.output = Relay(_output)
    }
}

input: Relay<SearchAPIStream.Input>output: Relay<SearchAPIStream.Output>はそれぞれ公開されているメソッドが限定されているため、依存しているものをpropertyで定義し外部からそれらに変更を加えられるようにします。
それでは、実際のテストケースの実装を見ていきます。
func setUp()では、テストターゲットとなるSearchViewStreamを、依存してるMockSearchAPIStream (SearchAPIStreamType)とともに初期化しています。

final class SearchViewtreamTests: XCTestCase {

    private var stream: SearchViewStream!
    private var mock: MockSearchAPIStream!

    override func setUp() {
        self.mock = MockSearchAPIStream()
        self.stream = SearchViewStream(searchAPIStream: mock)
    }
}

まず、入力のテストを見てみます。
SearchViewStreamの入力のsearchは、Logicfunc bind(from:) -> Output内でSearchAPIStreamのsearchに接続されています。
つまり、SearchAPIStreamのsearchから結果を確認することができます。
下記のように実装することで、Inputのテストが可能となります。

func testInput_search_is_called() {

    let expected = "test-search-text"
    let searchTextStack = BehaviorRelay<String?>(value: nil)

    let disposable = dependency.mock._input.search
        .bind(to: searchTextStack)

    stream.input.accept(expected, for: \.search)

    XCTAssertEqual(expected, searchTextStack.value)

    disposable.dispose()
}

次に、出力のテストを見てみます。
SearchViewStreamのoutputの出力のrepositoriesは、Logicfunc bind(from:) -> Output内でSearchAPIStreamのrepositoriesから接続されています。
つまり、SearchAPIStreamのrepositoriesから変更を通知することで確認することができます。
下記のように実装することで、Outputのテストが可能となります。

func testOutput_recieving_repositories() {

    let expected = [GitHub.Repository(...)]
    let repositoriesStack = BehaviorRelay<[Repository]?>(value: nil)

    let disposable = stream.output
        .observable(for: \.repositories)
        .bind(to: repositoriesStack)

    mock._outputRepositories.accept(expected)

    XCTAssertEqual(expected, repositoriesStack.value.first)

    disposable.dispose()
}

最後に

はじめにであげた①、②の冗長になる実装をKeyPathを利用することで改善し、①、②をまとめることができたことでInputOutputExtraを明示することができので③も改善できました。
それらはUnioを利用することで簡単に実装することができるので、是非試してみてください!

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