- 投稿日:2020-09-06T23:39:01+09:00
ファイルを保存するだけで動的にプログラムを更新できるInjectionIIIについて、macOSでもできるようにしてみた
以前、ファイルを保存するだけで動的にプログラムを更新できるInjectionIIIについての解説記事を投稿した。
その際は、対象がiOSだけだった。macOSでもできると書いてあったが、方法がわからず、ようやく少し進展した(まだ道半ば)。
https://qiita.com/KoichiroEto/items/5cb149a6e5d74bbdd66c3. macOSのプログラムからインジェクションしてみる
これまでは、iOS用アプリをインジェクションしていた。iOSアプリはシミュレーター上で動作しており、macOSアプリはそうではないという違いがある。そのため、いくつか追加の手順が必要となる。
3.1. なにかアプリを作る
まず、さきほどと同様に、なにかシンプルなアプリを開発する。
Xcodeを起動→Create a new Xcode Project→macOS→「App」→Next→Product Name:「MacTest」、User Interface: Storyboard→Next→「~/dev」を指定→Create
ViewController.swiftに、以下のようにshow()を追加。viewDidLoad()から呼ばれるようにする。ViewController.swiftclass ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() show() } func show() { let label = NSTextField(frame: NSRect(x: Int.random(in: 10..<100), y: Int.random(in: 10..<100), width: 150, height: 50)) label.backgroundColor = NSColor.cyan label.stringValue = "Hello, world!" view.addSubview(label) } }(TextFieldの位置がランダムなのは、諸事情がある。後述する。)
まずはこの段階で実行してみる。Cmd-R→ビルドされ、実行される。ウィンドウが表示され、「Hello, world!」が表示される。
ViewController.swiftに戻り、"Hello, world!"を"Hello, Japan!"に修正してCmd-Sで保存する。当然、何も反映されない。この時点ではまだインジェクションされていないからだ。
再度Cmd-Rすると、一旦アプリが終了し、再度立ち上げられ、"Hello, Japan!"が表示される。約3秒で立ち上がる。この速度ならあまり不満は持たれないかもしれない。
該当個所を、「Hello, world!」に戻しておく。インジェクションの設定を始めてみよう。3.2. プロジェクトを設定する
Xcodeに戻る。
Cmd-1→MacTestのプロジェクトを選択→PROJECT: MacTest→Build Settings→Linking→Other Linker Flags→ここにカーソルを乗せると左に三角が表示されるので、それをクリックする→Debugの右の「+」をおす→Any Architecture | Any SDK:「-Xlinker -interposable」→リターンを押すと確定する
Cmd-1→MacTestのプロジェクトを選択→TARGETS: MacTest→Signing & Capabilities→All→App Sandoboxの右の小さな「×」を押して、消す
Cmd-1→MacTestのプロジェクトを選択→TARGETS: MacTest→Signing & Capabilities→All→Hardened Runtime→「Disable Library Validation」をcheck3.3. Bundleを追加
AppDelegate.swiftにBundleを追加する。
AppDelegate.swift#if DEBUG Bundle(path: "/Applications/InjectionIII.app/Contents/Resources/macOSInjection.bundle")?.load() #endif参考までに、AppDelegate.swiftの該当するメソッド全体を示す。
AppDelegate.swiftfunc applicationDidFinishLaunching(_ aNotification: Notification) { #if DEBUG Bundle(path: "/Applications/InjectionIII.app/Contents/Resources/macOSInjection.bundle")?.load() #endif }3.4. injected()を追加
ViewController.swiftに、injectedというメソッドを追加する。ViewController.swift@objc func injected() { show() }参考までに、ViewController classの全体である。
ViewController.swiftimport Cocoa class ViewController: NSViewController { @objc func injected() { show() } override func viewDidLoad() { super.viewDidLoad() show() NotificationCenter.default.addObserver(self, selector: Selector("injected"), name: NSNotification.Name(rawValue: "INJECTION_BUNDLE_NOTIFICATION"), object: nil) } func show() { let label = NSTextField(frame: NSRect(x: Int.random(in: 10..<100), y: Int.random(in: 10..<100), width: 150, height: 50)) label.stringValue = "Hello, world!" label.backgroundColor = NSColor.cyan view.addSubview(label) } override var representedObject: Any? { didSet { // Update the view, if already loaded. } } }3.5. InjectionIIIにProjectを指定する
InjectionIIIが起動されていなかったら、起動する。
Status menuのInjectionIIIから「Open project」を選択→「~/dev/MacTest」を選択→「Select Project Directory」3.6. 起動する
Cmd-R→アプリが起動して、「Hello, world!」が表示される。
この状態で、Hello, world!を編集してみる。Cmd-Sで保存する。そうすると、即座にコンパイルされ、読み込まれ、classがreplaceされる。
また、injectedが呼ばれ、そこからshowが呼ばれる。
ただ、前のオブジェクトが残ってしまっている。そのため、以前のTextFieldは消去されない。そのまま残るだけである。
そのため、以前のversionでは場所が固定されているので、文字が変更されない。これが更新される方法は、これから調べる予定。とりあえず、今日はここまで!
- 投稿日:2020-09-06T23:13:18+09:00
Flutterアプリのスクショを極力自動で撮る(Riverpod使用・メソッドの濫用あり)
// TODO: 仕様追加。
// データベースに依存するRepositoryパターン。
// 別のProviderに依存するProviderがあり、UIはその両方に依存する。以前こんな記事を書きました。
Flutterアプリのスクショを(極力)自動で撮る on AndroidStudio
これでスクショ撮影はだいぶ簡単になった!!と思っていたのですが、
今回新たにゲーム要素のあるアプリを作成しまして、スクショ用の画面を作るためにそのゲームを攻略する必要が生じてしまいました。
ストア申請のために言語や端末を切り替えながら毎回ゲームを攻略するなんてとてもやってられません。
アプリコードに手を入れて数値を指定すればどうとでもなりますが、スクショを撮るためだけにあまりアプリを改造したくありません。
ということで、アプリコードを極力改造せず、必要な画面を自動的に生成しながら次々にスクショを撮る方法を確立しました。
アプリコード・スクショ撮るコードどちらも、break pointを打ってデバッグすることができます。
基本的な考え方
アプリの画面はアプリの状態の関数です。状態が決まれば画面が決まる。
画像は公式サイトのこちらから引用してます。
https://flutter.dev/docs/development/data-and-backend/state-mgmt/declarative
ということは、状態(state)さえ指定してやれば、欲しい画面は作れるということです。
今回の記事では、StateNotifierのstateと、Navigatorスタックの状態とTextEditingControllerの状態を直接指定することで、欲しい画面を生成します。
StateNotifierのstateの代わりに、ChangeNotifierのプロパティを指定することでも同様のことができると思いますが、試していません。アプリ内で使うChangeNotifierをimplementsして専用クラスを作れば良さそうな気がします。
メソッドの濫用は自己責任で
今回の提案手法ではこちらのメソッドを、本来の使用用途を超えた使い方で使います。
https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/requestData.html
「濫用する部分があります」というレベルではなく、ほぼ話の中心です。
スクショを撮るだけの目的なら恐らく問題ないとは思いますが、何か問題が生じても筆者は責任を負いかねますので自己責任でお願いします。
バージョン
この記事は2020年9月6日に書いており、下記のバージョンを使用しています。
NavigatorとRiverpodは使い方が大きく変更される可能性がそこそこあるので気をつけてください。
- flutter 1.20.2 (stable)
- hooks_riverpod 0.9.1
- flutter_hooks 0.13.2
- state_notifier 0.6.0
アプリの仕様
- Riverpod + StateNotifier + freezed で状態管理
- 日英中の3言語対応
- ページが複数あり、Navigatorを使って遷移する
- テキスト入力欄がある
Riverpodの機能を積極的に使います。
StateNotifier+freezedを使わず、ChangeNotifierを使ったパターンでも同様のことはできると思います。
一方、Riverpodなしで同様のことができるかどうかはわかりません。
前回記事の手法との違い
前回記事を読んでくださった方向けに述べますと…
改善点
- 言語切替も自動でできるようになった(かなり嬉しい!)
- 画面の状態を直接指定するのでアプリを操作する必要がなくなった
- Terminalからプロセスキルする必要が何故かなくなった。
- AndroidStudioの右上の
Stop Allを押せばOK- 何故?AndroidStudioのバージョンアップ???
改悪点
- メソッドを濫用している
- Riverpodに依存している(なしでできるかどうかは不明)
アプリのソースコード
こんな感じにします。
main.dart
普通にアプリを起動する場合のエントリーポイントです。
Riverpodの
ProviderScopeで囲んだMyAppをrunAppに渡すだけです。lib/main.dartimport 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'my_app.dart'; void main() { runApp(const ProviderScope(child: MyApp())); }my_app.dart
MaterialAppに必要な引数を渡してるだけです。
debugShowCheckedModeBannerとlocaleは、スクショ撮影時にいじれるように、Providerを使って渡すように改造しています。また、外から
Navigatorを操作できるように、コンストラクタで受け取ったGlobalKeyをMaterialAppのnavigatorKeyに渡しています。Navigatorを使わないアプリならこれは不要です。lib/my_app.dartimport 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'page1.dart'; // MaterialAppの引数の変更に応答して画面を変化させるため、HookWidgetにしておきます。 class MyApp extends HookWidget { // 外からNavigatorを操作するためのGlobalKeyをコンストラクタで受け取れるようにします。 const MyApp({this.navigatorKey}); final GlobalKey<NavigatorState> navigatorKey; @override Widget build(BuildContext context) { final materialAppArgs = useProvider(materialAppArgsProvider); return MaterialApp( navigatorKey: navigatorKey, debugShowCheckedModeBanner: materialAppArgs.showDebugBanner, locale: materialAppArgs.locale, supportedLocales: const [Locale('en'), Locale('ja'), Locale('zh')], localizationsDelegates: const [ GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, ], home: const Page1(), ); } } // MaterialAppに与える引数を書き換えられるようにprovideしておきます。 final materialAppArgsProvider = Provider((ref) => const MaterialAppArgs()); class MaterialAppArgs { // showDebugBannerはnullだとエラー。普段はtrueにします。スクショを撮る時はfalse。 // localeはnullでもいい。その場合デバイスの設定言語が使われます。 const MaterialAppArgs({bool showDebugBanner, this.locale}) : showDebugBanner = showDebugBanner ?? true; final bool showDebugBanner; final Locale locale; }page1.dart
アプリ画面本体の1つ目です。
画面の表示内容はデバイスの設定言語と、
StateNotifierをextendsしたSomethingControllerクラス(のstate)に依存しています。なお、記事に載せるソースコードを短くするため、別のページへの遷移は実装していませんのでアプリとしては機能しません!(それでも全ページのスクショが撮れるのが今回の手法です)
lib/page1.dartimport 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'something/something_controller.dart'; class Page1 extends HookWidget { const Page1(); @override Widget build(BuildContext context) { final a = useProvider(somethingProvider.state).a; final languageCode = Localizations.localeOf(context).languageCode; return Scaffold( appBar: AppBar( title: const Text('Page 1'), ), body: Column( children: [ // MyControllerのstateに依存 Text('a: $a'), // とってつけた多言語対応要素 Text(languageCode == 'zh' ? '你好谢谢' : languageCode == 'ja' ? 'こんにちはありがとう' : 'Hello. Thank you.'), ], ), ); } } final somethingProvider = StateNotifierProvider((ref) => SomethingController());page2.dart
アプリ本体のページ2つ目です。
StatelessWidgetです。
TextFieldがあり、そこに渡すTextEditingControllerはSomethingControllerが管理しています。lib/page2.dartimport 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:stepbystep/page1.dart'; class Page2 extends StatelessWidget { const Page2(); @override Widget build(BuildContext context) { final languageCode = Localizations.localeOf(context).languageCode; final somethingController = context.read(somethingProvider); return Scaffold( appBar: AppBar( title: const Text('Page 2'), ), // とってつけた多言語対応要素 body: Column( children: [ Text(languageCode == 'zh' ? '真的吗?' : languageCode == 'ja' ? 'マジ?' : 'Really?'), TextField(controller: somethingController.textController), ], ), ); } }something_controller.dart
このアプリ唯一の
StateNotifierです。でも別に今回の手法はStateNotifierがいくつあっても同様に適用できます。
Page1の表示内容と、Page2のTextFieldに渡すTextEditingControllerを管理しています。スクショ撮影のため、
TextEditingControllerのtextの初期値を外から渡せるように改造してあります。lib/something/something_controller.dartimport 'package:flutter/material.dart'; import 'package:state_notifier/state_notifier.dart'; import 'something_state.dart'; class SomethingController extends StateNotifier<SomethingState> { // TextEditingControllerのtextの初期値を外から受け取れるように改造します。 SomethingController({SomethingState state, String textValue}) : textController = TextEditingController(text: textValue), super(state ?? SomethingState()); final TextEditingController textController; }something_state.dart
freezed用の元ファイルです。プロパティはaだけ。lib/something/something_state.dartimport 'package:freezed_annotation/freezed_annotation.dart'; import 'package:flutter/foundation.dart'; part 'something_state.freezed.dart'; @freezed abstract class SomethingState with _$SomethingState { factory SomethingState({ @Default(0) int a, }) = _SomethingState; }スクショ撮影用ソースコード
ここからは、スクショ撮影専用のソースコードを紹介します。
ここがこの記事のメインです。
以下の2ファイルは
libではなくtake_ssというディレクトリを作ってそこに置いてます。main_for_ss.dart
スクショ撮影用にアプリを起動する際のエントリーポイントです。
これを使って、スクショ撮影専用の設定でアプリを起動します。
enableFlutterDriverExtensionとrequestDataFlutterDriverパッケージにある
enableFlutterDriverExtensionにhandler関数を渡しておきます。https://api.flutter.dev/flutter/flutter_driver_extension/enableFlutterDriverExtension.html
この
handlerは後にFlutterDriverのrequestDataによってアクセスでき、引数に与えられた文字列に応答して文字列を返します。https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/requestData.html
本来これは
requestDataという名の通り、FlutterDriverを使ったintegration testの最中にアプリからデータを取得してテストするためのものなのですが、今回はこの仕組みを悪用して、
handlerに副作用を持たせてアプリの状態をいじりまくります。
handler関数に渡された文字列に対し、アプリ画面の状態を定義します。画面状態を表す文字列
今回は
1-2-jaというハイフン区切りの文字列で「Page1の2つ目の画像で、言語は日本語」という画面を表すことにします。
ProviderContainerとUncontrolledProviderScopeRiverpodでは、すべての
ProviderはProviderContainerという所で管理されます。通常はWidgetツリー全体を
ProviderScopeの傘下に入れることで、ProviderContainerが自動的に用意されるので意識することはありません。ですが今回は
Providerを直接いじりたいので、外から触れるProviderContainerが必要です。
ProviderScopeの代わりにUncontrolledProviderScopeを使うと、外部で用意したProviderContainerを渡すことができるのでこれを利用します。後からoverrideする予定の
Providerは、ProviderScopeの生成時点で一度overrideしておく必要があります。後にupdateOverridesでそれを上書きします。
Navigator
Navigatorに外部から直接アクセスするためのGlobalKey<NavigatorState>もここで用意しています。外部から
NavigatorのpushAndRemoveUntilに新しいページを渡すことで強制的にページ遷移します。ソースコード
take_ss/main_for_ss.dartimport 'dart:io'; import 'package:device_info/device_info.dart'; import 'package:flutter/material.dart'; import 'package:flutter_driver/driver_extension.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:stepbystep/my_app.dart'; import 'package:stepbystep/something/something_controller.dart'; import 'package:stepbystep/something/something_state.dart'; import 'package:stepbystep/page1.dart'; import 'package:stepbystep/page2.dart'; void main() { // 後にoverrideし直す予定のProviderははじめからoverrideしておく必要がある。 final providerContainer = ProviderContainer(overrides: [ somethingProvider .overrideWithValue(SomethingController(state: SomethingState(a: 50))), materialAppArgsProvider.overrideWithValue( const MaterialAppArgs(showDebugBanner: false, locale: Locale('en'))), ]); // Navigatorにアクセスするためのグローバルキー final navigatorKey = GlobalKey<NavigatorState>(); enableFlutterDriverExtension(handler: (action) async { // このget_device_infoの場合だけ、この機能本来の使い方 if (action == 'get_device_info') { final _deviceInfo = DeviceInfoPlugin(); if (Platform.isAndroid) { return (await _deviceInfo.androidInfo).device; } else { return (await _deviceInfo.iosInfo).name; } // ここから、スクショ用の画面を定義していきます // actionに渡ってきた文字列によって画面を場合分け。 } else if (action.startsWith('1-')) { Navigator.of(navigatorKey.currentContext).pushAndRemoveUntil<void>( MaterialPageRoute(builder: (_) => const Page1()), (route) => false); if (action.startsWith('1-1-')) { providerContainer.updateOverrides([ somethingProvider.overrideWithValue( SomethingController(state: SomethingState(a: 100))), ]); } else if (action.startsWith('1-2-')) { providerContainer.updateOverrides([ somethingProvider.overrideWithValue( SomethingController(state: SomethingState(a: 3141592))), ]); } } else if (action.startsWith('2-')) { Navigator.of(navigatorKey.currentContext).pushAndRemoveUntil<void>( MaterialPageRoute(builder: (_) => const Page2()), (route) => false); // TextFieldの初期値を指定して、入力中の文字列をセットします。 providerContainer.updateOverrides([ somethingProvider .overrideWithValue(SomethingController(textValue: 'OH! YEAH!!')), ]); } // 言語を切り替えます final lang = action.substring(action.length - 2); providerContainer.updateOverrides([ materialAppArgsProvider.overrideWithValue( MaterialAppArgs(showDebugBanner: false, locale: Locale(lang))), ]); return ''; }); // UncontrolledProviderScopeには、自分で用意したProviderContainerを渡すことが出来ます。 // そのProviderScopeを使って外から各種Providerにアクセスします。 runApp(UncontrolledProviderScope( container: providerContainer, child: MyApp( // NavigatorにアクセスするためのGlobalKey navigatorKey: navigatorKey, ))); }take_ss.dart
最後に、このDartプログラムを実行することでスクショを撮影します。
FlutterDriverのrequestDataに、欲しい画面を表す文字列を渡していくことで次々に必要な画面を作り出してスクショを撮影していきます。take_ss/take_ss.dartimport 'dart:async'; import 'dart:io'; import 'package:flutter_driver/flutter_driver.dart'; Future<void> main() async { FlutterDriver driver; driver = await FlutterDriver.connect(); // ファイル名に含めるためのデバイス名を取得しておきます final _deviceName = (await driver.requestData('get_device_info')) .replaceAll(RegExp(r'\s'), ''); for (final lang in ['ja', 'zh', 'en']) { // アプリ状態を操作するメソッドを呼び出す await driver.requestData('1-1-$lang'); await takeScreenshot( driver, './screenshots/${lang}_${_deviceName}_1_1.png'); // もし画面遷移がアニメーションを伴う場合はこれで待ちます。多くの場合不要です。 await driver.waitForCondition(const NoPendingFrame()); } for (final lang in ['ja', 'zh', 'en']) { await driver.requestData('1-2-$lang'); await takeScreenshot( driver, './screenshots/${lang}_${_deviceName}_1_2.png'); await driver.waitForCondition(const NoPendingFrame()); } for (final lang in ['ja', 'zh', 'en']) { await driver.requestData('2-1-$lang'); await takeScreenshot( driver, './screenshots/${lang}_${_deviceName}_2_1.png'); await driver.waitForCondition(const NoPendingFrame()); } // このcloseは必要です。ないとプログラムの実行が終わらない。 await driver.close(); } // スクショを撮る関数 Future<void> takeScreenshot(FlutterDriver driver, String path) async { final pixels = await driver.screenshot(); final file = File(path); await file.writeAsBytes(pixels); print('took a screenshot $file'); }エディタ側の設定
上で紹介した「前回記事」とまったく同じなので、リンクを貼って割愛します。AndroidStudioの場合の説明しかありませんので、VSCodeやその他の方はうまく応用してください?
main_for_ss.dartの起動設定
https://qiita.com/agajo/items/2d2d57561b5880618966#androidstudio%E5%81%B4%E3%81%AE%E8%A8%AD%E5%AE%9A
take_ss.dartの起動設定
https://qiita.com/agajo/items/2d2d57561b5880618966#androidstudio%E5%81%B4%E3%81%AE%E8%A8%AD%E5%AE%9A-1さぁ、撮ってみよう!
保存先として
screenshotsというディレクトリを作成しておきます。iPhone2つとiPad2つの計4デバイスで、3つの場面を、3言語分撮ってみましょう。
4*3*3=36毎の画像になります。
あらかじめシミュレーターを4つ起動しておき、各シミュレーター上で
take_ss/main_for_ss.dartを起動 →take_ss/take_ss.dartでスクショを撮るという動作をします。
screenshotsディレクトリの中身がこうなったら終了。ストア申請時に扱いやすいように、名前順に並び替えると言語別→デバイス別に並びます。前回記事よりかなり早くスクショを撮り終えることが出来ました。やったね!!
もっと複雑なケース
この記事では手法の肝だけを説明するため、
StateNotifierをextendsしたクラスは作らず、クラスは同じでStateだけを変えたインスタンスを使ってProviderのoverrideを行っていますが、実際には下記のような対処が必要になると思います。下記のサンプルコードは、ここまで使ったサンプルアプリとは関係ないです。
StateNotifierに初期化処理があり、
stateが書き換わるケースせっかく普段と違うstateを持ったStateNotifierを作ったのに、初期化処理が走って内容を変えられてしまっては困ります。
StateNotifierである
SomethingControllerをextendsしたFakeSomethingControllerを作って次のようにしましょう。UIから、初期化メソッドを呼んでいる時
そのメソッドをoverrideして初期化処理が走らないようにしましょう。
// NG!! こう書いてはだめ!!!! @override void initThisController();こう書くとoverrideされていないので気をつけましょう。
正しくはこう
@override void initThisController(){}コンストラクタ内で初期化している時
それはサブクラスからは止められないので、その後サブクラス側でまたstateを上書きすればよいでしょう。
参考
【Dart】クイズ!extendsしたクラスのコンストラクタ実行順序!Repositoryパターンのケース
Repositoryの初期化処理が走ってデータベースへのアクセスを確保しようとし、失敗してエラーが出ることがあります。
Repositoryを
implementsしたFakeなんとかRepositoryを作って、データベースへのアクセスが発生しないようにしましょう。RepositoryをStateNotifierにコンストラクタDIしているなら、StateNotifierをoverrideする際にFakeのRepositoryを渡せばOKです。
そもそもそのStateNotifierもFakeにしている場合は、そちらには始めからFakeのRepositoryを持たせればいいですね。
例class FakeRankingController extends RankingController { FakeRankingController({RankingState state}) : super( state: state, // はじめからFakeRepositoryを持たせる rankingRepository: FakeRankingRepository()); // 初期化処理で何もしないようにする @override Future<void> initThisController() {} } class FakeRankingRepository implements RankingRepository { // データベースへのアクセスをしないようにする @override Future<List<Duration>> getData() {} }この
FakeRankingControllerを使ってProviderのoverrideを行えばOKです。別のProviderに依存するProviderがあり、その両方をoverrideしたいケース
gameControllerProviderがtimerProviderに依存している場合、このように両方まとめてoverrideするとエラーが出ます。providerContainer.updateOverrides([ timerProvider.overrideWithValue(StateController(Duration.zero)), gameControllerProvider .overrideWithValue(FakeGameController(state: GameState())), ]);Error in Flutter application: Uncaught extension error while executing request_data: 'package:riverpod/src/framework/container.dart': Failed assertion: line 247 pos 9: 'unusedOverrides.isEmpty': Updated the list of overrides with providers that were not overriden before「一度もoverrideしていないproviderのoverrideをupdateしようとしています。」というエラーが出ていますが、これは正しくないです。というのも、
ProviderContainerを宣言した時点で、該当providerは全て一度overrideしています。エラーメッセージが食い違っていますので、Riverpodのバグなのかもしれません。(2020年9月8日時点)
このように一つずつoverrideすると回避できます。
providerContainer ..updateOverrides([ timerProvider.overrideWithValue(StateController(Duration.zero)), ]) ..updateOverrides([ gameControllerProvider .overrideWithValue(FakeGameController(state: GameState())), ]);原因の仮説
timerProviderをoverrideすると、それに依存しているgameControllerProviderも自動的にoverrideされます。その瞬間、「override可能なproviderのリスト」的な所からgameControllerProviderが除去されてしまい、その後改めてgameControllerProviderをoverrideする際にエラーになるものと思われます。これは順番を入れ替えても解決しません。
どちらのproviderも指定した値でoverrideしたいので、これでは困ってしまいますね。上記のように2回に分ければ解決できます。
まだ残る課題
- 端末切り替えのたびにXcodeビルドが走るが、それを止めて、前と同じバイナリを使いたい
- 端末の切り替えも自動化出来ないかしら…
- そもそも論、訴求力の高いストア用スクショを作るためにやるべきことはもっと別かも??
皆さん結局どうやってストア用スクショ用意してるんでしょう。
ということで、今回も終わります!
- 投稿日:2020-09-06T23:13:09+09:00
Python(Kivy)で作るオセロアプリ(iOSアプリ)
はじめに
Pythonでマルチタップアプリを開発するためのオープンソースライブラリkivyを使ってオセロアプリを作成してみました。
また、最終的にiOSのSimulatorで(xcode)でビルドしました。環境
python: 3.7.7
kivy: 1.11.1
xcode: 11.7作成物
ソースコード解説
今回作成したオセロアプリを開発した順番を追いながら説明します。
1. オセロ盤面と初期石を配置
class OthelloApp(App): title = 'オセロ' def build(self): return OthelloGrid() OthelloApp().run()APPクラスの中でOthelloGridクラスをreturnしている。
今回のアプリの処理はこのOthelloGridクラスの中で行うようにしている。class OthelloGrid(Widget): def __init__(self, **kwargs): super().__init__(**kwargs) self.num = 8 self.tile = [[' ' for x in range(self.num)] for x in range(self.num)] self.turn = 'W' self.grid = GridLayout(cols=self.num, spacing=[3,3], size=(Window.width, Window.height)) for x in range(self.num): for y in range(self.num): if x == 3 and y == 3 or x == 4 and y == 4: self.grid.add_widget(WhiteStone()) self.tile[x][y] = 'W' elif x == 4 and y == 3 or x == 3 and y == 4: self.grid.add_widget(BlackStone()) self.tile[x][y] = 'B' else: self.grid.add_widget(PutButton(background_color=(0.451,0.3059,0.1882,1), background_normal='', tile_id=[x, y])) self.add_widget(self.grid)
self.numはオセロ盤面の縦、横のマスの数である。
self.tileは盤面の状態を記憶しておくためのリストで白が置かれている時'W'、黒が置かれている時'B'、何も置かれていない時' 'の値をとる。
self.turnは現在のターンが黒か白かを記憶しているもので、初期は白ターンから始まるようにしている。
実際に画面に描画する盤面はself.grid(GridLayout)で定義しており、既に石が置かれているマスには白の石の場合WhiteStoneクラス、黒の石の場合BlackStoneクラスをadd_widgetしており、まだ石が置かれていないマスにはPutButtonクラスをadd_widgetしています。class WhiteStone(Label): def __init__(self, **kwargs): super().__init__(**kwargs) self.bind(pos=self.update) self.bind(size=self.update) self.update() def update(self, *args): self.canvas.clear() self.canvas.add(Color(0.451,0.3059,0.1882,1)) self.canvas.add(Rectangle(pos=self.pos, size=self.size)) self.canvas.add(Color(1,1,1,1)) self.canvas.add(Ellipse(pos=self.pos, size=self.size)) class BlackStone(Label): def __init__(self, **kwargs): super().__init__(**kwargs) self.bind(pos=self.update) self.bind(size=self.update) self.update() def update(self, *args): self.canvas.clear() self.canvas.add(Color(0.451,0.3059,0.1882,1)) self.canvas.add(Rectangle(pos=self.pos, size=self.size)) self.canvas.add(Color(0,0,0,1)) self.canvas.add(Ellipse(pos=self.pos, size=self.size)) class PutButton(Button): def __init__(self, tile_id, **kwargs): super().__init__(**kwargs) self.tile_id = tile_id
WhiteStone、BlackStoneクラスはLabelクラスを継承しており、単純にマスRectangle(pos=self.pos, size=self.size)の上に楕円の石Ellipse(pos=self.pos, size=self.size)を描画しているだけです。
PutButtonクラスはButtonクラスを継承しており、まだ押した時の処理はありません。
tile_idとして、grid上のどの位置のマスかをインスタンス自身が記憶できるようにしています。2.マスをタップした時に石を置く
下記のように
PutButtonクラスにon_pressファンクションを作成し、マスをタップされた時の処理を追加する。PutButtonクラスdef on_press(self): put_x = self.tile_id[0] put_y = self.tile_id[1] turn = self.parent.parent.turn self.parent.parent.tile[put_x][put_y] = turn self.parent.parent.put_stone()
put_x、put_yにタップされたマスの番号を代入し、turnに現在のターンを代入する。
親のクラス(OthelloGrid)のtileのタップされたマスの場所にturnの値を代入し、put_stoneファンクションを呼び出す。
put_stoneはOthelloGridに下記のように作成したファンクションで、tileの中身から盤面を再作成するファンクションである。OthelloGridクラスdef put_stone(self): self.clear_widgets() self.grid = GridLayout(cols=self.num, spacing=[3,3], size=(Window.width, Window.height)) next_turn = 'W' if self.turn == 'B' else 'B' for x in range(self.num): for y in range(self.num): if self.tile[x][y] == 'W': self.grid.add_widget(WhiteStone()) elif self.tile[x][y] == 'B': self.grid.add_widget(BlackStone()) else: self.grid.add_widget(PutButton(background_color=(0.451,0.3059,0.1882,1), background_normal='', tile_id=[x, y]))3.挟んだ石をひっくり返す(ひっくり返せないマスをタップした場合は何もしない)
下記のように
OthelloGridクラスにマスの座標と現在のターンを起点にひっくり返すことができる石があるかをチェックするファンクションcan_reverse_checkとreverse_listを追加する。OthelloGridクラスdef can_reverse_check(self, check_x, check_y, turn): check =[] # 左上確認 check += self.reverse_list(check_x, check_y, -1, -1, turn) # 上確認 check += self.reverse_list(check_x, check_y, -1, 0, turn) # 右上確認 check += self.reverse_list(check_x, check_y, -1, 1, turn) # 右確認 check += self.reverse_list(check_x, check_y, 0, 1, turn) # 右下確認 check += self.reverse_list(check_x, check_y, 1, 1, turn) # 下確認 check += self.reverse_list(check_x, check_y, 1, 0, turn) # 左下確認 check += self.reverse_list(check_x, check_y, 1, -1, turn) # 左確認 check += self.reverse_list(check_x, check_y, 0, -1, turn) return check def reverse_list(self, check_x, check_y, dx, dy, turn): tmp = [] while True: check_x += dx check_y += dy if check_x < 0 or check_x > 7: tmp = [] break if check_y < 0 or check_y > 7: tmp = [] break if self.tile[check_x][check_y] == turn: break elif self.tile[check_x][check_y] == ' ': tmp = [] break else: tmp.append((check_x, check_y)) return tmp
can_reverse_checkでは石を置こうとしているマスにからそれぞれの方向に対して、ひっくり返すことができる石があるかをチェックするファンクションであるreverse_listを呼んでいる。
戻り値として、ひっくり返すことができる石の座標のリストが返ってくるようにしている。下記のように、この
can_reverse_checkをPutButtonクラスがタップされた時に(on_press内で)呼び出し、戻り値のリストの中身があった場合tileの値を更新して、盤面を作り直す(put_stoneを呼び出す)。
リストの中身が無かった場合は何もしない。PutButtonクラスdef on_press(self): put_x = self.tile_id[0] put_y = self.tile_id[1] check =[] turn = self.parent.parent.turn check += self.parent.parent.can_reverse_check(self.tile_id[0], self.tile_id[1], turn) if check: self.parent.parent.tile[put_x][put_y] = turn for x, y in check: self.parent.parent.tile[x][y] = turn self.parent.parent.put_stone()4.パス機能とゲーム終了時の処理の追加
OthelloGridクラスのput_stoneを下記のように拡張するOthelloGridクラスdef put_stone(self): pass_flag = True finish_flag = True check = [] self.clear_widgets() self.grid = GridLayout(cols=self.num, spacing=[3,3], size=(Window.width, Window.height)) next_turn = 'W' if self.turn == 'B' else 'B' for x in range(self.num): for y in range(self.num): if self.tile[x][y] == 'W': self.grid.add_widget(WhiteStone()) elif self.tile[x][y] == 'B': self.grid.add_widget(BlackStone()) else: self.grid.add_widget(PutButton(background_color=(0.451,0.3059,0.1882,1), background_normal='', tile_id=[x, y])) for x in range(self.num): for y in range(self.num): if self.tile[x][y] == ' ': finish_flag = False check += self.can_reverse_check(x, y, next_turn) if check: pass_flag = False break if finish_flag: content = Button(text=self.judge_winner()) popup = Popup(title='Game set!', content=content, auto_dismiss=False, size_hint=(None, None), size=(Window.width, Window.height/3)) content.bind(on_press=popup.dismiss) popup.open() else: if pass_flag: skip_turn_text = 'White Turn' if self.turn == 'B' else 'Black Turn' content = Button(text='OK') popup = Popup(title=skip_turn_text+' Skip!', content=content, auto_dismiss=False, size_hint=(None, None), size=(Window.width, Window.height/3)) content.bind(on_press=popup.dismiss) popup.open() else: self.turn = next_turn self.add_widget(self.grid)
pass_flagとfinish_flagを用意し、パスするかゲームを終了するかの判定に用いる。
tileの中の何も置かれていない全てのマス(値が' 'のマス)に対して、次のターンのプレイヤーがそのマスに石を置いた時にひっくり返す石があるかを確認し、もしなければ次のターンをスキップする。
その際にPopupでスキップしたことを画面に表示するようにする。もし、
tileの中の何も置かれていないマスがなければゲーム終了とみなし、下記のjudge_winnerファンクションでどちらが勝ったかを判別して、Popupで画面に表示する。OthelloGridクラスdef judge_winner(self): white = 0 black = 0 for x in range(self.num): for y in range(self.num): if self.tile[x][y] == 'W': white += 1 elif self.tile[x][y] == 'B': black += 1 print(white) print(black) return 'White Win!' if white >= black else 'Black Win!'ここまでで、オセロの処理としては一通り終わりとなります。
ソースコード全体
他にもResetButtonやターンを表示するラベルなども追加していますが、その辺りは下記のソースコード全体でご確認ください。
(gitにもあげています。https://github.com/fu-yuta/kivy-project/tree/master/Othello)main.pyfrom kivy.app import App from kivy.uix.widget import Widget from kivy.core.window import Window from kivy.uix.gridlayout import GridLayout from kivy.uix.boxlayout import BoxLayout from kivy.uix.button import Button from kivy.uix.label import Label from kivy.uix.popup import Popup from kivy.graphics import Color, Ellipse, Rectangle class OthelloGrid(Widget): def __init__(self, **kwargs): super().__init__(**kwargs) self.num = 8 self.tile = [[' ' for x in range(self.num)] for x in range(self.num)] self.turn = 'W' self.grid = GridLayout(cols=self.num, spacing=[3,3], size_hint_y=7) for x in range(self.num): for y in range(self.num): if x == 3 and y == 3 or x == 4 and y == 4: self.grid.add_widget(WhiteStone()) self.tile[x][y] = 'W' elif x == 4 and y == 3 or x == 3 and y == 4: self.grid.add_widget(BlackStone()) self.tile[x][y] = 'B' else: self.grid.add_widget(PutButton(background_color=(0.451,0.3059,0.1882,1), background_normal='', tile_id=[x, y])) self.creat_view('White Turn') def put_stone(self): self.grid = GridLayout(cols=self.num, spacing=[3,3], size_hint_y=7) pass_flag = True finish_flag = True check = [] next_turn = 'W' if self.turn == 'B' else 'B' for x in range(self.num): for y in range(self.num): if self.tile[x][y] == 'W': self.grid.add_widget(WhiteStone()) elif self.tile[x][y] == 'B': self.grid.add_widget(BlackStone()) else: self.grid.add_widget(PutButton(background_color=(0.451,0.3059,0.1882,1), background_normal='', tile_id=[x, y])) for x in range(self.num): for y in range(self.num): if self.tile[x][y] == ' ': finish_flag = False check += self.can_reverse_check(x, y, next_turn) if check: pass_flag = False break if finish_flag: content = Button(text=self.judge_winner()) popup = Popup(title='Game set!', content=content, auto_dismiss=False, size_hint=(None, None), size=(Window.width, Window.height/3)) content.bind(on_press=popup.dismiss) popup.open() self.restart_game() else: if pass_flag: skip_turn_text = 'White Turn' if self.turn == 'B' else 'Black Turn' content = Button(text='OK') popup = Popup(title=skip_turn_text+' Skip!', content=content, auto_dismiss=False, size_hint=(None, None), size=(Window.width, Window.height/3)) content.bind(on_press=popup.dismiss) popup.open() else: self.turn = next_turn turn_text = 'Black Turn' if self.turn == 'B' else 'White Turn' self.creat_view(turn_text) def can_reverse_check(self, check_x, check_y, turn): check =[] # 左上確認 check += self.reverse_list(check_x, check_y, -1, -1, turn) # 上確認 check += self.reverse_list(check_x, check_y, -1, 0, turn) # 右上確認 check += self.reverse_list(check_x, check_y, -1, 1, turn) # 右確認 check += self.reverse_list(check_x, check_y, 0, 1, turn) # 右下確認 check += self.reverse_list(check_x, check_y, 1, 1, turn) # 下確認 check += self.reverse_list(check_x, check_y, 1, 0, turn) # 左下確認 check += self.reverse_list(check_x, check_y, 1, -1, turn) # 左確認 check += self.reverse_list(check_x, check_y, 0, -1, turn) return check def reverse_list(self, check_x, check_y, dx, dy, turn): tmp = [] while True: check_x += dx check_y += dy if check_x < 0 or check_x > 7: tmp = [] break if check_y < 0 or check_y > 7: tmp = [] break if self.tile[check_x][check_y] == turn: break elif self.tile[check_x][check_y] == ' ': tmp = [] break else: tmp.append((check_x, check_y)) return tmp def judge_winner(self): white = 0 black = 0 for x in range(self.num): for y in range(self.num): if self.tile[x][y] == 'W': white += 1 elif self.tile[x][y] == 'B': black += 1 print(white) print(black) return 'White Win!' if white >= black else 'Black Win!' def restart_game(self): print("restart game") self.tile = [[' ' for x in range(self.num)] for x in range(self.num)] self.turn = 'W' self.grid = GridLayout(cols=self.num, spacing=[3,3], size_hint_y=7) for x in range(self.num): for y in range(self.num): if x == 3 and y == 3 or x == 4 and y == 4: self.grid.add_widget(WhiteStone()) self.tile[x][y] = 'W' elif x == 4 and y == 3 or x == 3 and y == 4: self.grid.add_widget(BlackStone()) self.tile[x][y] = 'B' else: self.grid.add_widget(PutButton(background_color=(0.451,0.3059,0.1882,1), background_normal='', tile_id=[x, y])) self.creat_view('White Turn') def creat_view(self, turn_text): self.clear_widgets() self.turn_label = Label(text=turn_text, width=Window.width , size_hint_y=1, font_size='30sp') self.restart_button = RestartButton(text='Restart') self.layout = BoxLayout(orientation='vertical', spacing=10, size=(Window.width, Window.height)) self.layout.add_widget(self.turn_label) self.layout.add_widget(self.grid) self.layout.add_widget(self.restart_button) self.add_widget(self.layout) class WhiteStone(Label): def __init__(self, **kwargs): super().__init__(**kwargs) self.bind(pos=self.update) self.bind(size=self.update) self.update() def update(self, *args): self.canvas.clear() self.canvas.add(Color(0.451,0.3059,0.1882,1)) self.canvas.add(Rectangle(pos=self.pos, size=self.size)) self.canvas.add(Color(1,1,1,1)) self.canvas.add(Ellipse(pos=self.pos, size=self.size)) class BlackStone(Label): def __init__(self, **kwargs): super().__init__(**kwargs) self.bind(pos=self.update) self.bind(size=self.update) self.update() def update(self, *args): self.canvas.clear() self.canvas.add(Color(0.451,0.3059,0.1882,1)) self.canvas.add(Rectangle(pos=self.pos, size=self.size)) self.canvas.add(Color(0,0,0,1)) self.canvas.add(Ellipse(pos=self.pos, size=self.size)) class PutButton(Button): def __init__(self, tile_id, **kwargs): super().__init__(**kwargs) self.tile_id = tile_id def on_press(self): print(self.tile_id) put_x = self.tile_id[0] put_y = self.tile_id[1] check =[] turn = self.parent.parent.parent.turn check += self.parent.parent.parent.can_reverse_check(self.tile_id[0], self.tile_id[1], turn) if check: self.parent.parent.parent.tile[put_x][put_y] = turn for x, y in check: self.parent.parent.parent.tile[x][y] = turn self.parent.parent.parent.put_stone() class RestartButton(Button): def __init__(self, **kwargs): super().__init__(**kwargs) def on_press(self): content = Button(text='OK') popup = Popup(title='Restart Game!', content=content, auto_dismiss=False, size_hint=(None, None), size=(Window.width, Window.height/3)) content.bind(on_press=popup.dismiss) popup.open() self.parent.parent.restart_game() class OthelloApp(App): title = 'オセロ' def build(self): return OthelloGrid() OthelloApp().run()iOS Simulaterでのビルド
下記の記事を参考にさせていただきました。
https://qiita.com/sobassy/items/b06e76cf23046a78ba05Xcodeのコマンドラインツールが入っていない場合は下記コマンドを実行してください。
xcode-select --install依存関係をインストールする
brew install autoconf automake libtool pkg-config brew link libtoolCythonをインストールする
pip install cythonkivy-iosをgit cloneする。
git clone https://github.com/kivy/kivy-ios.git cd kivy-iosiOS用のkivyをビルドするために下記コマンドを実行する(完了までに数十分ほどかかるかも)
python toolchain.py build kivy上記が完了したらkivyプログラムをXcode用にビルドする。
python toolchain.py create [Xcodeのプロジェクト名(任意の名前)] [kivyプログラムのフォルダ名]この時、kivyプログラムのファイルの名前は
main.pyにしておかなければならない。
Xcodeのプロジェクト名をAppとした場合、App-iosというディレクトリが作成されており、その中にApp.xcodeprojが作成されている。
このプロジェクトをXcodeで開く。open App.xcodeprojXcodeでSimulatorを指定してビルドすれば、アプリが立ち上がるハズである。
もし、kivyプログラムを更新した場合には、下記コマンドでXcodeのプロジェクトも更新しなければならない。python toolchain.py update App終わりに
pythonのライブラリの1つであるkivyを使って、オセロアプリを作成し、iOSのSimulaterでビルドしてみました。
今回は、main.pyの中で全ての処理を書いていましたがkivyにはkv言語というもので、wighetなどを分けて書くことができるので、そちらへの移植も今後考えていきたい。
また、オセロのAIを組み込んでプレイヤー対CPUの対戦機能も今後追加していきたい。参考
https://qiita.com/sobassy/items/b06e76cf23046a78ba05
https://github.com/PrestaMath/reverse_tile
- 投稿日:2020-09-06T23:05:36+09:00
App Store Connect でアプロードした時 Too many symbol files のかべにぶち当たったときの対処方法
- dSYM ファイルがいっぱいあることが原因っぽい (この辺は曖昧)
- 調べると dSYM ファイルをどうにかするっていう対処法ばっかりだったからそう思ってる
調べた感じ2つ
1つ目
プロジェクトファイルの debug information format を
DWARFにする
dSYMファイルを生成しないという設定らしい (多分)
でも Firebase Crashlytics で dSYM ファイルを使用してどうのこうのするっぽいから生成しないのはちょっとなと思う2つ目
Podfile に
config.build_settings['VALID_ARCHS'] = 'arm64'を追加するこれで余分な architecture のdSYMファイルを生成しないでよいっぽい
アーカイブしたファイル(.xcarchive)のパッケージの内容を表示して、dSYMsのフォルダ以下で dwarfdump --uuid * を実行すると下記のように、ライブラリごとに対応しているarchitecture 一覧が見れる。
下記はオプションを入れる前の状態
dSYMs % dwarfdump --uuid * UUID: 081C9609-5C30-3CB0-84B7-DF3326EF8146 (arm64) Kingfisher.framework.dSYM/Contents/Resources/DWARF/Kingfisher UUID: 24163A65-4498-37DB-BEBF-F6227DBFF77C (arm64) Realm.framework.dSYM/Contents/Resources/DWARF/Realm UUID: 4165D0CD-F99B-36EB-BC2F-8661D5056F33 (arm64) RealmSwift.framework.dSYM/Contents/Resources/DWARF/RealmSwift UUID: 70DDE517-8A61-3CE3-B1F4-E4B23FBBAD38 (armv7) Rswift.framework.dSYM/Contents/Resources/DWARF/Rswift UUID: B1138461-D58C-34A9-805C-CAB35DFC9141 (arm64) Rswift.framework.dSYM/Contents/Resources/DWARF/Rswift UUID: 8A08736F-4BF9-3F50-8A33-B1F2A4A44AAA (arm64) RxCocoa.framework.dSYM/Contents/Resources/DWARF/RxCocoa UUID: 0EBDB9F9-30C9-3E3C-9C84-B8FFAFF152FE (arm64) RxRelay.framework.dSYM/Contents/Resources/DWARF/RxRelay UUID: 8EC6ED8C-9989-32F8-88EF-8D4FED75868B (arm64) RxSwift.framework.dSYM/Contents/Resources/DWARF/RxSwift UUID: 5D9C7297-AE8C-362F-AB92-72926B9243A2 (armv7) SwiftyBeaver.framework.dSYM/Contents/Resources/DWARF/SwiftyBeaver UUID: DA55A53C-0AB8-35D6-B00B-3BF11D0A060A (arm64) SwiftyBeaver.framework.dSYM/Contents/Resources/DWARF/SwiftyBeaver下記がオプションを入れた状態
UUID: 081C9609-5C30-3CB0-84B7-DF3326EF8146 (arm64) Kingfisher.framework.dSYM/Contents/Resources/DWARF/Kingfisher UUID: 24163A65-4498-37DB-BEBF-F6227DBFF77C (arm64) Realm.framework.dSYM/Contents/Resources/DWARF/Realm UUID: 4165D0CD-F99B-36EB-BC2F-8661D5056F33 (arm64) RealmSwift.framework.dSYM/Contents/Resources/DWARF/RealmSwift UUID: B1138461-D58C-34A9-805C-CAB35DFC9141 (arm64) Rswift.framework.dSYM/Contents/Resources/DWARF/Rswift UUID: 8A08736F-4BF9-3F50-8A33-B1F2A4A44AAA (arm64) RxCocoa.framework.dSYM/Contents/Resources/DWARF/RxCocoa UUID: 0EBDB9F9-30C9-3E3C-9C84-B8FFAFF152FE (arm64) RxRelay.framework.dSYM/Contents/Resources/DWARF/RxRelay UUID: 8EC6ED8C-9989-32F8-88EF-8D4FED75868B (arm64) RxSwift.framework.dSYM/Contents/Resources/DWARF/RxSwift UUID: DA55A53C-0AB8-35D6-B00B-3BF11D0A060A (arm64) SwiftyBeaver.framework.dSYM/Contents/Resources/DWARF/SwiftyBeaveramrv7(iPhone5, iPhone5c以下の端末)はメインプロジェクトで使用しないので、不要となる。
これでアップロードすればきっとだいじょうぶなはず。
- 投稿日:2020-09-06T22:21:53+09:00
[SwiftUI]NavigationViewのnavigationBarTitle位置に画像とテキストを両方入れる方法
実装するもの
SwiftUIにおけるSwiftUI NavigationViewでListからNavigationLinkで遷移する方法ではnavigationBarTitleの使い方についても触れましたが、ここでは以下の実装をする為に必要な方法をご紹介したいと思います。
Code
ContentViewstruct ContentView: View { var body: some View { VStack { TitleView(image: Image("SwiftUI"),titleName: "SwiftUI") .frame(width: UIScreen.main.bounds.width * 0.95, height: UIScreen.main.bounds.height * 0.1) List(1..<100) { num in NavigationLink(destination: Text("Lesson\(num)")) { Text("Lesson\(num)") } } } } }Code
TitleViewstruct TitleView: View { let image: Image let titleName: String var body: some View { HStack { VStack(alignment: .leading) { image .resizable() .frame(width: 50, height: 50) } ZStack { Text("\(titleName)") .fontWeight(.black) .foregroundColor(Color.black) .font(.largeTitle) } Spacer() } .padding() .background(Color.white) .clipShape(RoundedRectangle(cornerRadius: 10)) } }解説
現段階でnavigationBarTitleにImageとTextを入れる方法がない(と思う)為、TitleViewを作り、最上部にVstackで配置する事でそれらしく見せる事ができます。また.frame(width: UIScreen.main.bounds.width * 0.95, height: UIScreen.main.bounds.height * 0.1)では高さや幅をデバイスのサイズによって変更する為、綺麗な設計をする上では非常に有効ですので是非ご活用ください。
また使用するImageはassetにあらかじめご用意ください。最後に
普段は個人でSwiftUIでアプリを作っています。ハードも作るの大好きです。
よければ下記もチェックして下さい♪
https://twitter.com/oka_yuuji
note
https://note.com/oka_yuuji
- 投稿日:2020-09-06T21:03:17+09:00
[Swift]Eurekaライブラリのカスタマイズ集
はじめに
Eurekaライブラリとは
Eurekaライブラリとは、iOSの入力フォームなどを、高速かつ簡単に作成するためのライブラリである。
https://github.com/xmartlabs/Eureka背景
このライブラリの基本的な使い方は様々な記事で紹介されている。しかし、応用的な使い方や、マイナーなカスタマイズは中々載っていなかったため、私自身カスタマイズに苦労した。
私この経験を記事にすることで、皆様のお役に立てるのではないかと思い投稿させて頂いた。この記事の主なターゲット
- Eurekaライブラリの基本的な使用方法を知っている方
- Swift初心者の方
- Eurekaを使用して間も無く、もっと活用できるようになりたいと感じている方
編集履歴
- 2020年9月6日(日):本記事を投稿
カスタマイズ集
LabelRowにDisclosure Indicatorを表示
EurekaSample.swiftclass EurekaSample: FormViewController { override func viewDidLoad() { super.viewDidLoad() form.append(Section() { $0.append(LabelRow() { $0.cell.accessoryType = .disclosureIndicator }) }) } }LabelRowにSub Titleを追加
EurekaSample.swiftimport Eureka class EurekaSample: FormViewController { override func viewDidLoad() { super.viewDidLoad() form.append(Section() { $0.append(LabelRow() { $0.title = "title" $0.cellStyle = UITableViewCell.CellStyle.subtitle }.cellUpdate { cell, _ in cell.detailTextLabel?.text = "sub title" cell.detailTextLabel?.textColor = UIColor.systemGray }.onCellSelection { cell, row in self.navigationController?.pushViewController(UIViewController(), animated: true) }) }) } }PushRowの選択先のVCのSectionを複数にする
無理やりです。
EurekaSample.swiftimport Eureka class EurekaSample: FormViewController { private let dataList = [String]() override func viewDidLoad() { super.viewDidLoad() form.append(Section() { $0.append(PushRow<String>("tag") { $0.title = "title" // オプションで追加はしない //$0.options }.onPresent { from, to in // デフォルトのセクションタイトルを消す to.form.allSections.first?.header = nil // セクション追加 let section1 = Section("section1") section1.tag = "section1" let section2 = Section("section2") section2.tag = "section2" for data: String in self.dataList { section1.append(LabelRow() { $0.title = data }.onCellSelection { cell, row in // デフォルトのPushRowを同じ挙動をさせる self.navigationController?.popViewController(animated: true) (self.form.rowBy(tag: "tag") as! PushRow<String>).value = data }) } to.form.append(section1) to.form.append(section2) }) }) } }文字入力系RowのUIToolbarをカスタマイズ
EurekaSample.swiftimport Eureka class EurekaSample: FormViewController, UITextFieldDelegate { private var activeTextField: UITextField? = nil // TextFieldが選択された時 func textFieldDidBeginEditing(_ textField: UITextField) { // textFieldの参照先をメンバ変数で保持しておく self.activeTextField = textField } // Keyboardの上のUIToolbarの完了ボタン押下時の処理 @objc private func focusDelete() { // 参照先を保持しているTextFieldのFocusを外す activeTextField?.resignFirstResponder() } override func viewDidLoad() { super.viewDidLoad() form.append(Section() { $0.append(TextRow() { $0.title = "title" }.cellUpdate { cell, row in // ここでこのRowのTextFieldのDelegateをセットさせる cell.textField.delegate = self }) }) } // カスタマイズUIToolbar(右側にDone buttonのみを配置) override func inputAccessoryView(for row: BaseRow) -> UIView? { let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: self.view.bounds.width, height: 0)) toolbar.sizeToFit() var items = [UIBarButtonItem]() items.append(UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)) // DoneのActionを設定 let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(focusDelete)) items.append(doneButton) toolbar.items = items return toolbar } }SectionのHeader、Footerをカスタマイズ
EurekaSample.swiftimport Eureka class EurekaSample: FormViewController { override func viewDidLoad() { super.viewDidLoad() let section = Section() section.footer = { var footer = HeaderFooterView<UIView>(.callback({ let view = UIView(frame: CGRect(x: 10, y: 0, width: self.view.bounds.width - 20, height: 100)) return view })) footer.height = { 180 } footer.onSetupView = { view, _ in view.preservesSuperviewLayoutMargins = false let label = UILabel(frame: CGRect(x: 10, y: 0, width: self.view.bounds.width - 20, height: 100)) label.font = UIFont.systemFont(ofSize: 13) label.numberOfLines = 0 label.textAlignment = .left label.text = "footer description" label.sizeToFit() view.addSubview(label) } return footer }() form.append(section) } }曜日のPickerInlineRow
EurekaSample.swiftimport Eureka class EurekaSample: FormViewController { override func viewDidLoad() { super.viewDidLoad() form.append(Section() { $0.append(PickerInlineRow<Weekday>() { $0.options = [.sunday, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday] $0.displayValueFor = { guard let weekday = $0 else { return nil } return weekday.rawValue + "曜日" } }) }) } } enum Weekday: String { case sunday = "日" case monday = "月" case tuesday = "火" case wednesday = "水" case thursday = "木" case friday = "金" case saturday = "土" }月と日のみのPickerInlineRow
EurekaSample.swiftimport Eureka class EurekaSample: FormViewController { override func viewDidLoad() { super.viewDidLoad() form.append(Section() { $0.append(DoublePickerInlineRow<String, String>() { $0.firstOptions = { return (1...12).map { String($0) + "月" } } $0.secondOptions = { month in var days = [String]() switch month { case "4月", "6月", "9月", "11月": days = (1...30).map { String($0) + "日" } case "2月": days = (1...29).map { String($0) + "日" } default: days = (1...31).map { String($0) + "日" } } return days } $0.displayValueFor = { guard let monthDay = $0 else { return nil } return monthDay.a + monthDay.b } }) }) } }おわりに
以上になります。万が一ミスなどをお気づきになりましたら、お手数をおかけしてしまい申し訳ありませんが、コメントなどでお知らせして頂けると助かります。
今後も他のカスタマイズが気付き次第更新したいと思います。
- 投稿日:2020-09-06T21:03:17+09:00
[Swift]Eurekaライブラリの応用カスタマイズ集
はじめに
Eurekaライブラリとは
Eurekaライブラリとは、iOSの入力フォームなどを、高速かつ簡単に作成するためのライブラリである。
https://github.com/xmartlabs/Eureka背景
このライブラリの基本的な使い方は様々な記事で紹介されている。しかし、応用的な使い方や、マイナーなカスタマイズは中々載っていなかったため、私自身カスタマイズに苦労した。
私この経験を記事にすることで、皆様のお役に立てるのではないかと思い投稿させて頂いた。この記事の主なターゲット
- Eurekaライブラリの基本的な使用方法を知っている方
- Swift初心者の方
- Eurekaを使用して間も無く、もっと活用できるようになりたいと感じている方
編集履歴
- 2020年9月6日(日):本記事を投稿
カスタマイズ集
LabelRowにDisclosure Indicatorを表示
EurekaSample.swiftclass EurekaSample: FormViewController { override func viewDidLoad() { super.viewDidLoad() form.append(Section() { $0.append(LabelRow() { $0.cell.accessoryType = .disclosureIndicator }) }) } }LabelRowにSub Titleを追加
EurekaSample.swiftimport Eureka class EurekaSample: FormViewController { override func viewDidLoad() { super.viewDidLoad() form.append(Section() { $0.append(LabelRow() { $0.title = "title" $0.cellStyle = UITableViewCell.CellStyle.subtitle }.cellUpdate { cell, _ in cell.detailTextLabel?.text = "sub title" cell.detailTextLabel?.textColor = UIColor.systemGray }.onCellSelection { cell, row in self.navigationController?.pushViewController(UIViewController(), animated: true) }) }) } }PushRowの選択先のVCのSectionを複数にする
無理やりです。
EurekaSample.swiftimport Eureka class EurekaSample: FormViewController { private let dataList = [String]() override func viewDidLoad() { super.viewDidLoad() form.append(Section() { $0.append(PushRow<String>("tag") { $0.title = "title" // オプションで追加はしない //$0.options }.onPresent { from, to in // デフォルトのセクションタイトルを消す to.form.allSections.first?.header = nil // セクション追加 let section1 = Section("section1") section1.tag = "section1" let section2 = Section("section2") section2.tag = "section2" for data: String in self.dataList { section1.append(LabelRow() { $0.title = data }.onCellSelection { cell, row in // デフォルトのPushRowを同じ挙動をさせる self.navigationController?.popViewController(animated: true) (self.form.rowBy(tag: "tag") as! PushRow<String>).value = data }) } to.form.append(section1) to.form.append(section2) }) }) } }文字入力系RowのUIToolbarをカスタマイズ
EurekaSample.swiftimport Eureka class EurekaSample: FormViewController, UITextFieldDelegate { private var activeTextField: UITextField? = nil // TextFieldが選択された時 func textFieldDidBeginEditing(_ textField: UITextField) { // textFieldの参照先をメンバ変数で保持しておく self.activeTextField = textField } // Keyboardの上のUIToolbarの完了ボタン押下時の処理 @objc private func focusDelete() { // 参照先を保持しているTextFieldのFocusを外す activeTextField?.resignFirstResponder() } override func viewDidLoad() { super.viewDidLoad() form.append(Section() { $0.append(TextRow() { $0.title = "title" }.cellUpdate { cell, row in // ここでこのRowのTextFieldのDelegateをセットさせる cell.textField.delegate = self }) }) } // カスタマイズUIToolbar(右側にDone buttonのみを配置) override func inputAccessoryView(for row: BaseRow) -> UIView? { let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: self.view.bounds.width, height: 0)) toolbar.sizeToFit() var items = [UIBarButtonItem]() items.append(UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)) // DoneのActionを設定 let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(focusDelete)) items.append(doneButton) toolbar.items = items return toolbar } }SectionのHeader、Footerをカスタマイズ
EurekaSample.swiftimport Eureka class EurekaSample: FormViewController { override func viewDidLoad() { super.viewDidLoad() let section = Section() section.footer = { var footer = HeaderFooterView<UIView>(.callback({ let view = UIView(frame: CGRect(x: 10, y: 0, width: self.view.bounds.width - 20, height: 100)) return view })) footer.height = { 180 } footer.onSetupView = { view, _ in view.preservesSuperviewLayoutMargins = false let label = UILabel(frame: CGRect(x: 10, y: 0, width: self.view.bounds.width - 20, height: 100)) label.font = UIFont.systemFont(ofSize: 13) label.numberOfLines = 0 label.textAlignment = .left label.text = "footer description" label.sizeToFit() view.addSubview(label) } return footer }() form.append(section) } }曜日のPickerInlineRow
EurekaSample.swiftimport Eureka class EurekaSample: FormViewController { override func viewDidLoad() { super.viewDidLoad() form.append(Section() { $0.append(PickerInlineRow<Weekday>() { $0.options = [.sunday, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday] $0.displayValueFor = { guard let weekday = $0 else { return nil } return weekday.rawValue + "曜日" } }) }) } } enum Weekday: String { case sunday = "日" case monday = "月" case tuesday = "火" case wednesday = "水" case thursday = "木" case friday = "金" case saturday = "土" }月と日のみのPickerInlineRow
EurekaSample.swiftimport Eureka class EurekaSample: FormViewController { override func viewDidLoad() { super.viewDidLoad() form.append(Section() { $0.append(DoublePickerInlineRow<String, String>() { $0.firstOptions = { return (1...12).map { String($0) + "月" } } $0.secondOptions = { month in var days = [String]() switch month { case "4月", "6月", "9月", "11月": days = (1...30).map { String($0) + "日" } case "2月": days = (1...29).map { String($0) + "日" } default: days = (1...31).map { String($0) + "日" } } return days } $0.displayValueFor = { guard let monthDay = $0 else { return nil } return monthDay.a + monthDay.b } }) }) } }おわりに
以上になります。万が一ミスなどをお気づきになりましたら、お手数をおかけしてしまい申し訳ありませんが、コメントなどでお知らせして頂けると助かります。
今後も他のカスタマイズが気付き次第更新したいと思います。
- 投稿日:2020-09-06T20:46:32+09:00
[SwiftUI]NavigationViewでListからNavigationLinkで遷移する方法
今回の記事で実装できるもの
SwiftUIでNavigationViewを使えば本当に簡単に以下の様なものが作れます。
以下ではこの実装の解説をしていきます。
Code
struct ContentView: View { var body: some View { NavigationView { List(1..<100) { num in NavigationLink(destination: Text("Lesson\(num)")) { Text("Lesson\(num)") } }.navigationBarTitle("SwiftuUI") } } }これだけ少ないコードで上記の様な
・タイトル
・リスト
・遷移
を実装する事ができます。
ではどの様に実装しているか少し解説していきたいと思います。解説
NavigationView
・NavigationLink
・navigationBarTitle
はもちろんですが、その他にも
・navigationBarItems
など使用するには必須。List
(1..<100)でリストの個数を定義しています。今回は1〜99のリストを作成して数字はnumに返しているのでnumもそのままList内で使う事がきますのでText内などでも(\num)で使用する事ができます。
NavigationLink
destination以降でリンク先の表示するものを定義する事ができます。今回はテキストですがimageはもちろん、Viewを指定して画面を遷移させる事も可能です。
navigationBarTitle
("")内に書いたテキストをタイトル位置(画面の上部)に表示させる事ができます。
大きさは
("SwiftuUI", displayMode: .large)
displayModeの後に
large
inline
automatic
で指定する事ができます。以上です。是非参考になれば嬉しいです。
最後に
普段は個人でSwiftUIでアプリを作っています。ハードも作るの大好きです。
よければ下記もチェックして下さい♪
https://twitter.com/oka_yuuji
note
https://note.com/oka_yuuji
- 投稿日:2020-09-06T19:09:00+09:00
[Swift] 実務的Tips: Tupleのswitchで条件マトリックスをもれなく分岐させる
今日からでもすぐに取り入れられて、
- コードをよりクリーンにできる、とか
- 工数を削減できる、とか
そんなTipsを紹介していく記事シリーズです。
「知らなかった」「気づかなかった」「忘れていた」そんな誰かの役に立てば幸いです。前提環境
- Xcode 11.3.1
- Swift 5.1.3
Tupleのswitch
var isWeekday = false var isChildlen = false var isSenior = false switch (isWeekday, isChildlen, isSenior) { case (true, true, _): print("平日・子ども料金") case (true, _, true): print("平日・シルバー料金") case (true, _, _): print("平日・大人料金") case (false, true, _): print("休日・子ども料金") case (false, _, true): print("休日・シルバー料金") case (false, _, _): print("休日・大人料金") }
- Swiftの
switchは全ての組み合わせを網羅しないとコンパイルエラーにしてくれます。- それはTupleを
switchする場合も同様で、全要素が取りうる全組み合わせを網羅させることが容易になります。- 可読性はあまりよくないので、「全ての組み合わせを網羅する」ことを最優先にしたい場合以外は避けた方が良い書き方かもしれません。
- 投稿日:2020-09-06T19:04:35+09:00
[SwiftUI]EnvironmentObjectをあるViewに入れるとPreviewができない
SwiftUIでPreviewを使っていたところ、
このようなエラーに遭遇しました。Thread 1: Fatal error: No observable object of type DataManager found. A View.environmentObject(_:)現時点の最新のXcode11.6では、ビルドできるのにプレビューに失敗している時、以下の画像のように表示されます。Diagnosticsを押しても、よくわからないエラーが書かれています。
クラッシュログの場所を探す
正確なクラッシュログは
~/Library/Logs/DiagnosticReportsにあります。このディレクトリの中から該当するアプリのクラッシュログを探します。
中身を見てみると、一番初めのエラーを見つけました。問題のあるコード
Viewの構成は以下のようになっています。
ContentView -> SubView -> ChildViewContentView.swiftvar body: some View { SubView() } ... struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() .environmentObject(DataManager()) } }SubView.swiftvar body: some View { ChildView() } ... struct SubView_Previews: PreviewProvider { static var previews: some View { SubViewView() } }ChildView.swift@ObservableObject var dataManager: DataManager var body: some View { Text(self.manager.message) } ... struct ChildView_Previews: PreviewProvider { static var previews: some View { ChildViewView() .environmentObject(DataManager()) } }原因と解決策
ContentViewにDataManagerを
environmentObjectとして追加しているので、アプリ起動の際は問題ありませんでした。
しかし、プレビューができませんでした。
数時間をかけて解決策を探したところ、以下のような原因と解決方法を見つけました。原因
- ContentView_Previews:envirnmentObjectを追加している
- SubView_Previews:environmentObjectを追加していない
- ChildView_Previews:environmentObjectを追加している
SubView_PreviewsにenvironmentObjectを追加していなことが原因でした。
解決策
SubView.swiftstruct SubView_Previews: PreviewProvider { static var previews: some View { SubViewView() .environmentObject(DataManager()) //←このViewでは使ってないけどここにもenvironmentObjectを書かないといけない } }
- 投稿日:2020-09-06T17:03:50+09:00
Firebase Cloud Messagingを使ってアプリに通知機能を実装する
はじめに
本記事はFirebaseのプロジェクトが作成済みでアプリにすでにFirebaseを導入していることを前提とした内容となっています。また、iOSアプリを対象にしています。
Firebase Cloud Messaging(以降FCM)とは公式ドキュメントによるとFirebase Cloud Messaging(FCM)は、メッセージを無料で確実に送信するためのクロスプラットフォーム メッセージング ソリューションです。
ということです。通常アプリにプッシュ通知を送るにはアプリの設定以外にもAPNs(GCM)に通知送信をするためのアプリケーションサーバーを用意しないといけません。(Pusherなどのアプリで代用も可能)FCMであればAPNsに通知を送るための設定をFirebaseのコンソールから簡単に行うことができます。
プロジェクトの設定
Cloud Messagingに必要なライブラリのインストール
// Podfile pod 'Firebase/Messaging' // install $ pod installプッシュ通知に必要なCapabilityを設定
- TARGETS -> Signing & Capability -> + を押下
- その中からBackground ModesとPush Notificationsをダブルクリックで追加
- Background ModesのRemote notifiationsにチェック
この時点でプロジェクト名.entitlementsが追加されます。 中身を見ると
development用が追加されています。他の記事を見るとこれをコピーして本番用を用意してたりしたのですが、根拠となるソースが見つからなかったのでAppleのドキュメントを漁ったところ
Xcode sets the value of the entitlement based on your app's current
provisioning profile. For example, if you're using a development provisioning
profile, Xcode sets the value to development.プロジェクトに設定されているProvisioning Profileによって自動的に変更されるようです。
試しにProvisoning ProfileをDistributionに変えたんですが、書き換わることはありませんでした。
どうやらArchiveするときに自動的に値をセットしてくれる仕様のようです。
https://stackoverflow.com/questions/42292363/aps-environment-is-always-development
試しにアーカイブした中身を確認したところ
変更されていました。
また、AppStoreConnectにアップロードしたバイナリも確認しましたが、productionになっていました。
Push用のCertificateを作成
すでにアプリのIdentifiersが用意されている前提の説明となります。はじめから作る場合はこちらが参考になるかと思います。
- キーチェーンアクセスより証明書を発行(本番・開発用の計2つ)
- Identifiersをクリック
- Push Notifications -> Configure
![]()
- Development SSL Certificate -> Create Certificate
![]()
- 先程作った証明書を使ってCertificateを作成
- 本番も同様に作成
これら2つをダウンロード
~.cerをダブルクリックし、キーチェーンに登録
それぞれ右クリックし、「〜を書き出す」
FirebaseのCloud MessagingにAPNs証明書を登録
Firebaseコンソールの歯車を押して「プロジェクトを設定」
Settings -> Cloud MessagingからAPNs証明書の項目までスクロールし、先程作った証明書をアップロードします
AppDelegate.swiftに通知を受け取るための処理を追加
AppDelegate.swiftに以下を追加
import Firebase import FirebaseMessaging class AppDelegate: UIResponder, UIApplicationDelegate { ... func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. FirebaseApp.configure() // Optional Messaging.messaging().delegate = self UNUserNotificationCenter.current().delegate = self // 通知の許可をユーザーに要求する UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound],completionHandler: { granted, error in guard error == nil else { return } if granted { // registerForRemoteNotificationsは必ずメインスレッドで実行しなければならない DispatchQueue.main.async { // Appleプッシュ通知サービスを介してリモート通知を受信するための登録を行う application.registerForRemoteNotifications() } } }) return true } ... } // Optional extension AppDelegate: MessagingDelegate { func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String) { // テスト送信用のトークン取得 print("Firebase registration token: \(fcmToken)") } } extension AppDelegate : UNUserNotificationCenterDelegate { // アプリがフォアグラウンドで通知を受け取ったとき func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { let userInfo = notification.request.content.userInfo // Print full message. print(userInfo) // Change this to your preferred presentation option completionHandler([[.alert, .sound]]) } // ユーザーが通知バナーをタップしたとき func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { let userInfo = response.notification.request.content.userInfo // Print full message. print(userInfo) completionHandler() } }実装する上で何点か注意点があります。
まず一つ目として、iOS10未満を対象とするかで通知を受信したときのデリゲートメソッドの実装が異なります。
今回iOS10未満を対象としないため上記実装となります。もし、iOS10未満も対象にする場合は公式ドキュメントを参考に場合分けが必要です。
二つ目の注意点として、registerForRemoteNotificationsを実行する際はメインスレッドで行う必要があるということです。メインスレッドでの実行を明示しない場合は以下警告がでます。
三つ目はMessagingDelegateの実装です。こちらは実装しなくても通知は受け取れます。
ただ、Firebaseコンソール上での通知送信は開発・本番の区別なく送信されるため、テスト送信をしたい場合にはFCMトークンを指定してテスト送信をする必要がありました。
そのため、本番に送る前に手元で通知内容を確認したい場合にはFCMトークンを取得すると便利です。FirebaseコンソールからPushを送る
FirebaseコンソールのCloud Messaging -> 通知の作成から新たな通知を発行できます。
ここの「テストメッセージを送信」を選ぶと特定の端末に通知を送ることができます。
先程のAppdelegate内のデリゲートでプリントしたFCMトークンをここに追加して「テスト」を押せば通知を受け取れます。
テスト送信ができればあとは通知設定をしていき、最後に「公開」を押せば本送信されます。
参考
- 投稿日:2020-09-06T15:05:06+09:00
XCTContextを使ったお気楽Parameterized Test
はじめに
iOSのテストフレームワークはParameterized Testをサポートしていません。その制約を回避するために世の中にはいろいろな手法が提示されていますが、そのほとんどは設定や実装が面倒です。
そこで、私が実務を実際に行っている。XCTContextを使ってお気楽に行う方法を披露したいと思います。実現方法。
たったこんだけ。テスト結果にはちゃんとパラメータ毎のテスト結果がレポートされます。
func test_hogehoge() { for (name, parameter) in ["テスト1": 1, "テスト2": 2, "テスト3": 3] { XCTContext.runActivity(named: name) { activity in ... } } }さらに
パラメータ定義〜テスト実行部分をクロージャにとるメソッドとして実装すれば、もっと簡潔に重複を減らす形で実装できると思います。
- 投稿日:2020-09-06T15:05:06+09:00
XCTContextを使ったお手軽Parameterized Test
はじめに
iOSのテストフレームワークはParameterized Testをサポートしていません。その制約を回避するために世の中にはいろいろな手法が提示されていますが、そのほとんどは設定や実装が面倒です。
そこで、私が実務を実践している、XCTContextを使ってお手軽に行う方法を披露したいと思います。実現方法。
たったこんだけ。テスト結果にはちゃんとパラメータ毎のテスト結果がレポートされます。
func test_hogehoge() { for (name, expectedResult) in ["テスト1": 1, "テスト2": 2, "テスト3": 3] { XCTContext.runActivity(named: name) { activity in ... XCTAssertEqual(result, expectedResult) } } }さらに
パラメータ定義〜テスト実行部分をクロージャにとるメソッドとして実装すれば、もっと簡潔に重複を減らす形で実装できると思います。
- 投稿日:2020-09-06T09:40:13+09:00
背景に動画を流す方法
背景に動画を置く方法
画面の背景に動画をリピートで再生させ続けたい場合
viewController.swiftimport UIKit import AVFoundation class PopupDetailViewController: UIViewController { var player = AVPlayer() let path = Bundle.main.path(forResource: "Sample", ofType: "mov") override func viewDidLoad() { super.viewDidLoad() player = AVPlayer(url: URL(fileURLWithPath: path!)) player.play() let playerLayer = AVPlayerLayer(player: player) // フレームの大きさを決める playerLayer.frame = CGRect(x: 0, y: 0, width: view.frame.size.width, height: view.frame.size.height) playerLayer.videoGravity = .resizeAspectFill playerLayer.repeatCount = 0 playerLayer.zPosition = -1 view.layer.insertSublayer(playerLayer, at: 0) // リピートさせる NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { (notification) in self.player.seek(to: .zero) self.player.play() } }動画の保存場所
ここにファイルを移せば再生される
- 投稿日:2020-09-06T09:33:19+09:00
SwiftUIをジェネリクスでプレビューしやすくする
SwiftUIでアプリを構築していてプレビュー機能をフル活用しないことにはSwiftUI使ってる旨味を最大限に引き出せませんよね。
このWWDC動画でのプレビュー機能の活用方法がとってもわかりやすく実用的だったので、要点だけまとめて記事にします。
シンプルデータとリッチデータ
動画で語られているのが、アプリで取り扱うデータは主に2つに分類できると言及されています。
- リッチデータ ... CoreData, RealmやCloudKitなどの情報やサーバ側にしか無いデータなど
- シンプルデータ ... Stringなどのプリミティブなデータ型、構造体など
シンプルデータは生成や取得が容易でリッチデータは生成や取得へのレイヤーが深かったり手続きが面倒なものといったところでしょうか。
動画ではSwiftUIのView層は極力シンプルデータで構築しましょうと言っています。
仮にRealmやCloudKitのデータをViewで表示する場合であったとしてもリッチデータをそのままバインドするのではなくprotocolなどで抽象化し、シンプルデータとしてView側で表示するのを推奨しています。
リッチデータはテストやプレビューがしにくい
RealmやiCloudなどのリッチデータというのは生成がユーザ操作を経ないと出来ない場合や、ビューに必要ではない情報を多分に含んでいるケースなどが多く、テストやプレビューする時にはとても難儀です。
Realm上のデータをリストで表示みたいなユースケースを抽象化せずに実装していくとプレビューしにくいのは想像に難くないと思います。プレビューしずらい設計
import Foundation import SwiftUI import RealmSwift struct TodoView: View { @ObservedObject var viewModel: TodoViewModel var body: some View { List(self.viewModel.list, id: \.id) { todo in HStack { Image(systemName: todo.isComplete ? "checkmark.square" : "square") .foregroundColor(todo.isComplete ? .green : .secondary) Text(todo.title) } .padding(6) } } } struct ContentView_Previews: PreviewProvider { static let viewModel: TodoViewModel = TodoViewModel() static var previews: some View { TodoView(viewModel: viewModel) } } // MARK: viewModel class TodoViewModel: ObservableObject { @Published var list: [TodoEntity] private var dataSource: Realm init() { self.dataSource = try! Realm() self.list = self.dataSource.objects(TodoEntity.self).map { $0 } } } class TodoEntity: Object, Identifiable { @objc dynamic var id: String = UUID().uuidString @objc dynamic var title: String = "" @objc dynamic var isComplete: Bool = false }TodoリストをRealmで表示するケースでの実装パターン
このままだとPreviewProviderで空データのプレビューが表示されるだけでデータがある場合のプレビューが出来ません。
抽象化しておらず、Viewが詳細な実装に依存しているため、追加処理を実装するまでデータの一覧での確認するのが辛く、あまりよろしい設計とは言えません。今回のパターンは追加処理も簡単でプレビューしなくてもだいたいの画面はイメージできますが、より複雑なパターンや、RealmからCloudKitの載せ替えが発生した場合や、データをサーバーから取得する仕様に変更になった場合にサーバの実装が出来るまで待ち時間が発生してしまいます。
フロントエンドエンジニアとしてそれは由々しき問題なので、ビューからデータの発生源の関心を取り除きましょう。ジェネリクスを適用しモックに差し替えやすく
import Foundation import SwiftUI struct TodoView<T: TodoViewModelProtocol>: View { @ObservedObject var viewModel: T var body: some View { List(self.viewModel.list, id: \.id) { todo in HStack { Image(systemName: todo.isComplete ? "checkmark.square" : "square") .foregroundColor(todo.isComplete ? .green : .secondary) Text(todo.title) } .padding(6) } } } struct ContentView_Previews: PreviewProvider { class TodoViewModelMock: TodoViewModelProtocol { @Published var list: [TodoEntity] = [TodoEntity]() init() { self.list = [ TodoEntity(title: "first task", isComplete: true), TodoEntity(title: "second task", isComplete: false), TodoEntity(title: "third task", isComplete: true), ] } } static var previews: some View { TodoView<TodoViewModelMock>(viewModel: TodoViewModelMock()) } } // MARK: viewModel protocol TodoViewModelProtocol: ObservableObject { associatedtype ListData: TodoEntityProtocol var list: [ListData] { get set } } protocol TodoEntityProtocol { var id: String { get set } var title: String { get set } var isComplete: Bool { get set } } class TodoEntity: Object, Identifiable, TodoEntityProtocol { @objc dynamic var id: String = UUID().uuidString @objc dynamic var title: String = "" @objc dynamic var isComplete: Bool = false convenience init(title: String, isComplete: Bool) { self.init() self.title = title self.isComplete = isComplete } }
TodoViewModelProtocolとTodoEntityProtocolを新たに定義し、抽象化SwiftUIのView層はProtocolのみ知っている状態にし、ジェネリクスを用いて差し替え容易に
struct TodoView<T: TodoViewModelProtocol>: View { @ObservedObject var viewModel: T
PreviewProviderにモックデータ用のクラスを差し込めば実際のデータ状態に依存されずにプレビュー可能に。
モックデータ用のクラスはシンプルデータを自前で用意するだけで良くなりました。モッククラスを実行ファイルに同梱したくない場合は
Preview Contentフォルダに含めれば、製品版に不要なソースがバンドルされることもないので、とても有能です。SwiftUIのプレビューはデータ設計にも強力なツール
SwiftUIのプレビューは魅力的で、簡易なビューならすぐプレビューできますが、複雑なユースケースが絡んだ場合に安直に実装してしまうと途端にプレビューしにくくなってしまいます。
これをデメリットに感じてしまう人もいるかもしれませんが、UIフレームワークが実装者に設計を意識させる作り方になっているのだと感じました。抽象化のメリットを具体例で説明する時に毎回いい例を挙げれずに困っていましたが、今回のサンプルはいい例になるなと思い記事にしました。
UIKitは工夫しないとファットになってしまうアーキテクチャでしたが、SwiftUIは初期構想から実装者を良い設計に導くように作られているんだと思いました。
- 投稿日:2020-09-06T08:40:03+09:00
オプショナルへの予想を上回る気遣い
オプショナルの学習メモ
iOSアプリ開発において、オプショナルについて予想を上回る気遣いをしなければならないので、学習したことをメモしていきます。オプショナルについての内容を網羅している記事ではないのでご注意ください。
オプショナルとは?
マイページ設定の画面などには、入力すべき「必須項目」とそうでない「オプション項目」がある。学習している「オプショナル」とはここで言う「オプション項目」のことを指す。
何も値が入らない項目なので、値として「何もない = nil」をもてる変数に設定する。
※nilは空文字ではない。空文字は" "このスペース半角のように値が入っていることを表す。空文字はメモリに値を格納している。注意すべきは、何もない(= nil)をもてることが許されるということ
言い換えれば、普通は許されない。Swiftならではの設計。オプショナル変数の宣言方法
ポイントは型の後の?マーク!
変数にnilを代入var hoge: String? // = nilが代入できる※?だけでなく!をつけることもあるが、少し意味が異なるので注意。
オプショナル型の変数から値を取り出す
以下にコードの例を記述した。
エラーになる計算var age:Int? = 27 print(age + 1) //ageはオプショナル型であり整数型でないのでエラーオプショナル型の変数前に!を加える
これをアンラップ(unwrap)という。オプショナル型はnilを許容するようにラップ(wrap)されていて、それを剥がすというイメージ
※なお、以下で行っている値を取り出す処理を行う際に、変数の直後に"!"を記述して、アンラップすることを強制的アンラップ(Forced unwrap)というオプショナル型の変数から値を取り出すvar age:Int? = 27 print(age! + 1) // = 28なお、nilが代入されている時に"!"をつけると、クラッシュする
nilが代入されているvar age:Int? = nil print(age! + 1) //クラッシュつまり、オプショナル型の変数から値を取り出すときに"!"をつけることは、中身がnilではないことを保証しなければならない。
中身が絶対にnilではない時にしか"!"はつけてはいけない
とにかく上記が開発のミソ!!!!
中身がnilなのかどうかで設計の危険性が判断されたりする。オプショナル型のメリット
他の言語ではnilを参照するとアプリはクラッシュする。Swiftも同様であるが、変数宣言時にnilを含むことが許されるのでnilチェックが必要じゃなくなり開発の手間が省ける。
オプショナル関係のやつをまとめていく
大まかなことは上記に記した。その他の知識や役立つTIPSは以下にまとめていく。
オプショナルチェーン
プロパティやメソッドが数珠つなぎになった形から、オプショナル型の変数を取り出す際に変数の後に?をついているもの。取り出す変数がnilなら、指定したプロパティやメソッドは実行されない。
こんな形のやつ変数名?.メソッド.メソッド注意
オプショナルチェーンにおいて、オプショナル型の変数名の後に"!"をつけて値がnilだった場合はアプリがクラッシュする。オプショナルバインディング
オプショナル型の値に条件分岐をかけて値がnilかどうかで、処理が変わる
lf-let文の形になることが多いみたいオプショナルバインディングlet age: Int? if let me = age { print("合致しました") } else { print("nilです") //ageの値がnilなので、else以下が実行される }その他
guard let文を使うこと、map・flatmapメソッドとオプショナルの関係なども追記していきます。
- 投稿日:2020-09-06T06:09:50+09:00
Xcode 12 でのアプリ内購入のテスト
この記事では、Xcode 12 (beta) で提供されているツールを使用してアプリ内購入のテストを行うことについて説明します。
ヘルパークラスとスタートプロジェクト
私は、あなたがアプリ内購入を処理するのに役立つヘルパークラスを作成しました。そのファイルはここにあります。
こちら Github で完了したプロジェクトを見ることができます。このプロジェクトを実行するには、Xcode 12(iOS 14用です)を入手する必要があります。
このスタータープロジェクトでは、ユーザーが購入できる以下のアイテムを用意致します:
商品ID 商品名 商品タイプ virtualCatFood バーチャルキャットフード 消耗品 (Consumable) virtualCatHouse バーチャルキャットハウス 非消耗品 (Non-Consumable) dailyVirtualCatFood 毎日バーチャルキャットフードサブスクリプション サブスクリプション (Auto-Renewable)
StoreKit Configuration File先ず
command-Nをキーボードで入力しStoreKit Configuration Fileを作成してくださいアプリ内課金により購入される商品の設定する
これで設定の下方にあるプラスアイコンをクリックして新しく購入する商品を追加できます。
ここでは3種類のアイテムを追加できます:
タイプ 説明 消耗品 (Consumable) アプリ内購入した消耗品は、一度使うとなくなり、再度購入することができます。バーチャルキャットフードといったものです。 非消耗品 (Non-Consumable) 非消耗品は一度購入すると期限が切れることはありません。バーチャルキャットハウスといったものです。 自動更新アイテム (Auto-Renewable) ユーザーは、キャンセルするまで繰り返し請求されます。毎日与えるキャットフードの定期購入といったものです。 新しい項目を追加した後は、詳細事項を入力します。項目のID
Product ID、リファレンス名(リファレンスのみに使用)Reference Name、ローカライズ名Localizationsを入力する必要があります。テスト設定の構成
次に、作成したばかりの設定ファイルの内容をXcodeに知らせる必要があります。上部にあるアプリ名をクリックし、
Edit Schemeをクリックします。
StoreKit Configuration設定の場所をみつけて、それを作成した構成ファイルの名前に設定します。アプリ内購入のテスト
これで、シミュレーターまたはお使いのデバイスでアプリを実行できます。そして、購入を行うことができます。購入後にXcodeウィンドウにあるこのアイコンの場所を見つけて、それをクリックすると、すべての領収書が表示されます:
そのアイコンをクリックすると、今行ったすべての購入が表示されたウィンドウを確認することができます:
- 投稿日:2020-09-06T00:29:01+09:00
スマホアプリにおける署名について
Code Signing
iOS/Androidいずれの場合もアプリが改ざんされることを確認できる安全性を確保するために、ビルドしたアプリケーションに電子署名します。これがCode Signingとよばれます。
概念的には同じですが、ストアとの関係やツールの操作方法などがそれぞれ異なるため、混乱しそうです。
(これにビルドサイト(CircleCI/Bitrise等)を利用するとさらにサインするための情報を設定する必要があり、さらに複雑になります)電子署名のざっくりした説明
電子署名する
電子署名は改ざんを防止や否認防止などで利用され、PKI(公開鍵インフラストラクチャ)の技術です。あるバイナリ(テキストデータもバイナリ化したうえで)を符号化した情報に対して秘密鍵で暗号化します。その値(A)と符号化、暗号化アルゴリズムを署名のメタデータとして含めます。
署名の検証
検証する場合は同様にバイナリを署名のメタデータである符号化アルゴリズムで符号化します。また含まれている暗号化された値(A)を公開鍵で複号化して一致しているかを確認します。
ここで元のバイナリが改ざんされると符号化された情報も変わるため一致しなくなり、改ざんされていることが検知されます。
公開鍵が改ざんされていると当然、この検証が無効になってしまうため、公開鍵が改ざんされていないかを確認します。そのため、公開鍵は証明書の形で受け渡され、その証明書は他の機関によって電子署名されているというチェーンによってその公開鍵の改ざんを検知します。
このあたりの保証・管理の仕方がPKIとして実現されています。実際は、バイナリを分割してハッシュを取得したり、メタデータの格納方式などがAndroid、iOSで決まっています。
概要
上記のPKIの技術を使って、バイナリを保護することになりますが、Android/iOS共通して
* 証明書の取り扱い(証明書と紐付いた秘密鍵の取り扱い(鍵管理)
* アプリケーションID(バンドルID)
* 開発者
をどう結びつけるかという観点では同じものになります。これらの取り扱いがそれぞれ違うことが難しさを呼んでるといえます。
Android
アプリへの署名の仕方
Android Studioのメニュー(Build>Generatote Signed Bundle or APK)から署名することができます。
この時にKey storeのパス(以下の鍵管理参照)、パスワード、鍵名称(エイリアス)、鍵パスワードが求められます。
この証明書の期限25年にしておくのが良いです(短くしたらGoogle Playでリリースできなかった)鍵管理
AndroidのアプリでCode Signで必要な鍵を管理しているのは、JavaのKeystoreを利用しています。
但し、keytoolコマンドは利用せず、上記の通りAndroid Studioを使って利用することができます。Keystoreは鍵管理ファイルとそのパスワード、各鍵の名称(鍵)とパスワードで構成されています。
この仕組からKeyStoreでは自己署名証明書と呼ばれる証明書にて管理されることになります。
IssuerとSubjectが同じ内容になり、iOSがAppleのCAによって署名された証明書を利用するのとは異なります。コマンドによる署名
Android Studioでも内部ではGradleを利用して署名しているので、CIなどのビルドプロセスで実行する場合はGradleでビルドをすることができます。以下にビルド方法を記載します。ソースコード(build.gradle)にパスワードを平文で記載するのはセキュリティ上、好ましくないのですが説明上、そうしていますので実際にはプロパティファイルに設定して、プロパティファイルを構成管理対象としないなどの工夫が必要となります。
build.gradlesigningConfigs { release { keyAlias 鍵エイリアス keyPassword 鍵パスワード storeFile Key storeファイル名 storePassword storeパスワード } } buildTypes { release { signingConfig signingConfigs.release } }アプリケーションID
AndroidアプリのIDはAndroid Studioでプロジェクトを作るときに指定します。このIDは、appフォルダのbuild.gradleに設定されます。
app/build.gradledefaultConfig { applicationId "xxx.xxx.xxx.xxx" }このIDはGoogle Playで公開する際にURLの一部(play.google.com/store/apps/details?id= このidの後ろにアプリIDをつける)となるため、重複しないようにJavaのパッケージと同様にドメインを逆に記載したものをもとに作成するのが良いと思います。
Google Play 設定(開発者との紐付け)
リリースするにはGoogle Playにリリース登録する必要があります。
Google Play はGoogleの開発者登録をして、ログインできるようになるPlay Consoleからアプリを登録することができます。Consoleから作成した、アプリバンドルをアップロードしてリリース対象のファイルとします。(内部テストなどテストをへて、公開します)また、Google PlayにはGoogle Play アプリ署名(アップロードする鍵と配布する鍵を変える)という機能があり、鍵管理の観点からはこちらを利用するのがいいと思います。
iOS
iOSでもCode Signingが必要です。Google Playとは異なり、Apple Storeでは鍵を変えて署名してくれることはないので、ipaを作成するときに毎回署名します。
鍵管理
MacOSのアプリケーションであるKeyChainのアプリケーションにて、秘密鍵を管理します。正確に表現するとCSR(証明書要求)を作ったときに秘密鍵も作られることになります。
(AndroidではKey storeで管理しているのと同様です)証明書の種類
署名で利用される秘密鍵に対応する署名書は二種類あります。
- Developer証明書(開発用)
- Distribution証明書(配布用)Adhoc配布とApple Store配布用です
証明書をCAにて署名する
オレオレ証明書ではなく、上記の証明書はそれぞれApple DeveloperサイトにてAppleの決まったCAによって電子署名して利用されます。(登録した証明書のみが利用できる)
そのため、上記で記載したCSRをもとにCAによって電子署名する方法で実現します。
Apple Developerサイトによる署名
PKIの一般用語では証明書をCAにて電子署名を依頼し、実施することでオレオレ証明書ではなく証明書チェーンに従う証明書にします。これをAppleのiOS開発においては「Certificates」にて登録、証明書を作成します。作成した証明書をダウンロードして、ダブルクリックすることでキーチェーンアプリにて秘密鍵と関連付けで保存されます。
この証明書はDER形式で保存されているので、openssslコマンドなどで中身を確認できます。openssl x509 -inform der -in download.cer -textなどとすると中身がわかります。IssuerのOUは「Apple Worldwide Developer Relations」でした。
SubjectのCNが「Apple Distribution: XXX」になっていて、ここが配布用であることを表しています。アプリケーションID(Bundle ID)
Androidは内部の記載をもとにAPKファイルをアップロードする際に登録されていましたが、iOSの場合はApple Developerサイトで登録する必要があります。
Identifiersメニューから追加します。いろいろな種類がありますが、「App IDs」がスマホアプリのIDとなります。
Wildcard/ExplictのバンドルIDの種類を選んで設定します。XCodeにここで登録したBundleIDとして設定をします。
Provisioning File(開発者との紐付け)
AndroidはアップロードするAPKに含まれている署名が正になっており、PlayConsoleにログインしてアップロードすることでそのアップロード用の署名とユーザが紐付けられますが、iOSではアプリケーションID、証明書及び端末(Adhoc配布の場合など)をあわせてProfileを作成します。
XCodeでの設定
このプロファイルを利用してアプリ署名を実施します。
コード署名を自動的にする設定をするとXCodeが自動的にプロファイルを作ってくれます。アプリケーションIDがなければ作成してくれます。証明書はAppleの署名が必要なので登録が必要ですが、アプリケーションID、プロファイルは作らなくて利用可能です。まとめ
PKIの知識をもっていれば、順をおって確認すればわかることですが、構成要素が多いのと使いやすくするためにツール化されているので少しわかり行くいと思いました。次は、iOSのコマンドでの手順を調べてCIできるサイトでの設定の理解(bitrise等)につなげたいと思います。
- 投稿日:2020-09-06T00:04:12+09:00
はじめての ReactorKit【実践編】
前回の概要編に続き、今回は実際に ReactorKit を使ったサンプル実装をしていきたいと思います。
作るアプリ
今回は Google Books API を使用したアプリを想定して実装していきたいと思います。仕様は下記の通りです。
※ 基本的には、ReactorKit 周りの実装が中心なので細かい API 処理などの実装部分などは省いていきます。
- 画面が開いたら、API からデータを取得して TableView に反映する
refreshButtonがタップされたら、データを更新する- API からデータを取得している最中は
activityIndicatorを表示し、取得が完了したら非表示にする作業開始??
まずは View のロジックを担う Reactor にそれぞれのイベントとデータを定義していきます。
import ReactorKit import RxSwift import RxCocoa // 後々 concat() 関数も使いたいのでインポート class BookListViewReactor: Reactor { enum Action { case load case refresh } // Mutation を定義しない場合は、Action が Mutation として扱われる enum Mutation { case setBooks([ServerBook]) case setLoading(Bool) } struct State { var books: [ServerBook] var isLoading: Bool } var initialState: BookListViewReactor.State = State(books: [], isLoading: false) }
Action・State・initialStateあたりは必須 Reactor プロトコルの定義で必須で、Mutationに関しては、Action をもとに実行される処理の具体的な結果の値を定義します。また、Mutationの定義は必須ではなく、定義が無い場合にはActionがMutationとして扱われます。次に
Actionをもとに処理を実行してMutationを返すためのmutate(action:)と、その受け取ったMutationをもとに新しいStateを返すreduce(state:, mutation:)関数を定義していきます。// BookListViewReactor func mutate(action: Action) -> Observable<Mutation> { switch action { case .load: return Observable.concat([ Observable.just(Mutation.setLoading(true)), // API から本の情報一覧を取得 BookService.sharedInstance.getAllBooks().flatMap { item in Observable.just(Mutation.setBooks(item.items))}, Observable.just(Mutation.setLoading(false)) ]) case .refresh: return Observable.concat([ Observable.just(Mutation.setLoading(true)), BookService.sharedInstance.getAllBooks().flatMap { item in Observable.just(Mutation.setBooks(item.items))}, Observable.just(Mutation.setLoading(false)) ]) } } func reduce(state: State, mutation: Mutation) -> State { switch mutation { case .setLoading(let isLoading): var newState = state newState.isLoading = isLoading return newState case .setBooks(let books): var newState = state newState.books = books return newState } }
mutate(action:)の中では、主にObservable.cancat()でMutaiontを返しています。今回 Action として定義したloadのように1つのイベントでloading の更新やAPI からデータを取得など複数の処理を行う必要があるので、RxCocoa が提供しているconcat()関数で Observable を直列に実行して、順次Mutatationを返しています。また、reduce(state:, mutation:)では受け取ったMutationと現在のStateをもとに新しいStateを発行して返しています。これで、Reactor 側の実装は完了したので、View の実装をしていきます。
まずは、使用する View(ViewController) を
ReactorKitが提供しているViewプロトコルに準拠させます。また、今回は、Storyboard を使用して View を作成していくので、StoryboardViewというプロトコルに準拠させます。これによって、ViewController の childViews が初期化されたタイミングでbind(reactor:)が呼ばれるようになります。import ReactorKit import RxSwift class BookListViewController: UIViewController, StoryboardView { @IBOutlet weak var refreshButton: UIButton! @IBOutlet weak var activityIndicator: UIActivityIndicatorView! @IBOutlet weak var tableView: UITableView! // ? 本来はより、Testable にするために Reactor の注入はクラス内では行いませんが、今回はサンプル実装のためこのままでいきます var reactor: BookListViewReactor? = BookListViewReactor() var disposeBag: DisposeBag = DisposeBag() override func viewDidLoad() { super.viewDidLoad() tableView.register(UINib(nibName: "BookTableViewCell", bundle: nil), forCellReuseIdentifier: "BookTableViewCell") } func bind(reactor: BookListViewReactor) { Observable.just(Void()) .map { Reactor.Action.load } .bind(to: reactor.action) .disposed(by: disposeBag) refreshButton.rx.tap .map { Reactor.Action.refresh } .bind(to: reactor.action) .disposed(by: disposeBag) // State binding. reactor.state .map { $0.isLoading } .distinctUntilChanged() // 値に変更があった場合にのみイベントを流す .map { !$0 } .bind(to: activityIndicator.rx.isHidden) .disposed(by: disposeBag) reactor.state .map { $0.books } .bind(to: tableView.rx.items(cellIdentifier: "BookTableViewCell", cellType: BookTableViewCell.self)) { index, book, cell in cell.set(book: book) } .disposed(by: disposeBag) } }基本的には、
disposeBagとbind(reactor:)の定義が必須になります。今回はサンプル実装なので、reactorへの反映をクラス内で行っていますが、本来はより Testable にするために、切り離す必要があります(初回表示の View の場合は、AppDelegate 内で reactor の反映を行うなど)。また、bind(reactor:)が呼ばれるのは、reactorへの反映が完了していて、かつviewDidLoadの後に呼び出されます。ですので、初回時に行う処理などをbind(reactor:)でrx.methodInvoked(#selector(viewDidLoad))のように Observe したいところですが、これは呼ばれないので初回時の処理はObservable.just(Void())で定義します(参考のIssue-comment)。こんな感じで、ReactorKit を使ってシンプルにアプリを作成することができました? また他の場面で使用することがあったらまた記事を書きたいと思います。
参考



































