20200906のiOSに関する記事は19件です。

ファイルを保存するだけで動的にプログラムを更新できるInjectionIIIについて、macOSでもできるようにしてみた

以前、ファイルを保存するだけで動的にプログラムを更新できるInjectionIIIについての解説記事を投稿した。
その際は、対象がiOSだけだった。macOSでもできると書いてあったが、方法がわからず、ようやく少し進展した(まだ道半ば)。
https://qiita.com/KoichiroEto/items/5cb149a6e5d74bbdd66c

3. 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.swift
class 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」をcheck

3.3. Bundleを追加

AppDelegate.swiftにBundleを追加する。

AppDelegate.swift
        #if DEBUG
    Bundle(path: "/Applications/InjectionIII.app/Contents/Resources/macOSInjection.bundle")?.load()
        #endif

参考までに、AppDelegate.swiftの該当するメソッド全体を示す。

AppDelegate.swift
    func 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.swift
import 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では場所が固定されているので、文字が変更されない。これが更新される方法は、これから調べる予定。

とりあえず、今日はここまで!

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

Flutterアプリのスクショを極力自動で撮る(Riverpod使用・メソッドの濫用あり)

// TODO: 仕様追加。
// データベースに依存するRepositoryパターン。
// 別のProviderに依存するProviderがあり、UIはその両方に依存する。

以前こんな記事を書きました。

Flutterアプリのスクショを(極力)自動で撮る on AndroidStudio

これでスクショ撮影はだいぶ簡単になった!!と思っていたのですが、

今回新たにゲーム要素のあるアプリを作成しまして、スクショ用の画面を作るためにそのゲームを攻略する必要が生じてしまいました。

ストア申請のために言語や端末を切り替えながら毎回ゲームを攻略するなんてとてもやってられません。

アプリコードに手を入れて数値を指定すればどうとでもなりますが、スクショを撮るためだけにあまりアプリを改造したくありません。

ということで、アプリコードを極力改造せず、必要な画面を自動的に生成しながら次々にスクショを撮る方法を確立しました。

アプリコード・スクショ撮るコードどちらも、break pointを打ってデバッグすることができます。

基本的な考え方

アプリの画面はアプリの状態の関数です。状態が決まれば画面が決まる。

ui-equals-function-of-state-54b01b000694caf9da439bd3f774ef22b00e92a62d3b2ade4f2e95c8555b8ca7.png

画像は公式サイトのこちらから引用してます。

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で囲んだMyApprunAppに渡すだけです。

lib/main.dart
import '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に必要な引数を渡してるだけです。

debugShowCheckedModeBannerlocaleは、スクショ撮影時にいじれるように、Providerを使って渡すように改造しています。

また、外からNavigatorを操作できるように、コンストラクタで受け取ったGlobalKeyMaterialAppnavigatorKeyに渡しています。Navigatorを使わないアプリならこれは不要です。

lib/my_app.dart
import '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つ目です。

画面の表示内容はデバイスの設定言語と、StateNotifierextendsしたSomethingControllerクラス(のstate)に依存しています。

なお、記事に載せるソースコードを短くするため、別のページへの遷移は実装していませんのでアプリとしては機能しません!(それでも全ページのスクショが撮れるのが今回の手法です)

lib/page1.dart
import '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があり、そこに渡すTextEditingControllerSomethingControllerが管理しています。

lib/page2.dart
import '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の表示内容と、Page2TextFieldに渡すTextEditingControllerを管理しています。

スクショ撮影のため、TextEditingControllertextの初期値を外から渡せるように改造してあります。

lib/something/something_controller.dart
import '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.dart
import '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

スクショ撮影用にアプリを起動する際のエントリーポイントです。

これを使って、スクショ撮影専用の設定でアプリを起動します。

enableFlutterDriverExtensionrequestData

FlutterDriverパッケージにあるenableFlutterDriverExtensionhandler関数を渡しておきます。

https://api.flutter.dev/flutter/flutter_driver_extension/enableFlutterDriverExtension.html

このhandlerは後にFlutterDriverrequestDataによってアクセスでき、引数に与えられた文字列に応答して文字列を返します。

https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/requestData.html

本来これはrequestDataという名の通り、FlutterDriverを使ったintegration testの最中にアプリからデータを取得してテストするためのものなのですが、

今回はこの仕組みを悪用して、handlerに副作用を持たせてアプリの状態をいじりまくります。

handler関数に渡された文字列に対し、アプリ画面の状態を定義します。

画面状態を表す文字列

今回は1-2-jaというハイフン区切りの文字列で「Page1の2つ目の画像で、言語は日本語」という画面を表すことにします。

ProviderContainerUncontrolledProviderScope

Riverpodでは、すべてのProviderProviderContainerという所で管理されます。

通常はWidgetツリー全体をProviderScopeの傘下に入れることで、ProviderContainerが自動的に用意されるので意識することはありません。

ですが今回はProviderを直接いじりたいので、外から触れるProviderContainerが必要です。

ProviderScopeの代わりにUncontrolledProviderScopeを使うと、外部で用意したProviderContainerを渡すことができるのでこれを利用します。

後からoverrideする予定のProviderは、ProviderScopeの生成時点で一度overrideしておく必要があります。後にupdateOverridesでそれを上書きします。

https://pub.dev/documentation/flutter_riverpod/latest/flutter_riverpod/UncontrolledProviderScope-class.html

Navigator

Navigatorに外部から直接アクセスするためのGlobalKey<NavigatorState>もここで用意しています。

外部からNavigatorpushAndRemoveUntilに新しいページを渡すことで強制的にページ遷移します。

ソースコード

take_ss/main_for_ss.dart
import '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プログラムを実行することでスクショを撮影します。

FlutterDriverrequestDataに、欲しい画面を表す文字列を渡していくことで次々に必要な画面を作り出してスクショを撮影していきます。

take_ss/take_ss.dart
import '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ディレクトリの中身がこうなったら終了。ストア申請時に扱いやすいように、名前順に並び替えると言語別→デバイス別に並びます。

スクリーンショット 2020-09-06 23.03.37.png

前回記事よりかなり早くスクショを撮り終えることが出来ました。やったね!!

もっと複雑なケース

この記事では手法の肝だけを説明するため、StateNotifierextendsしたクラスは作らず、クラスは同じでStateだけを変えたインスタンスを使ってProviderのoverrideを行っていますが、実際には下記のような対処が必要になると思います。

下記のサンプルコードは、ここまで使ったサンプルアプリとは関係ないです。

StateNotifierに初期化処理があり、stateが書き換わるケース

せっかく普段と違うstateを持ったStateNotifierを作ったのに、初期化処理が走って内容を変えられてしまっては困ります。

StateNotifierであるSomethingControllerextendsした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したいケース

gameControllerProvidertimerProviderに依存している場合、このように両方まとめて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ビルドが走るが、それを止めて、前と同じバイナリを使いたい
  • 端末の切り替えも自動化出来ないかしら…
  • そもそも論、訴求力の高いストア用スクショを作るためにやるべきことはもっと別かも??

皆さん結局どうやってストア用スクショ用意してるんでしょう。

ということで、今回も終わります!

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

Python(Kivy)で作るオセロアプリ(iOSアプリ)

はじめに

Pythonでマルチタップアプリを開発するためのオープンソースライブラリkivyを使ってオセロアプリを作成してみました。
また、最終的にiOSのSimulatorで(xcode)でビルドしました。

環境

python: 3.7.7
kivy: 1.11.1
xcode: 11.7

作成物

完成形
Sep-06-2020 20-30-30.gif

ソースコード解説

今回作成したオセロアプリを開発した順番を追いながら説明します。

1. オセロ盤面と初期石を配置

下記の状態まで作成
スクリーンショット 2020-09-06 20.44.30.png

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

WhiteStoneBlackStoneクラスはLabelクラスを継承しており、単純にマスRectangle(pos=self.pos, size=self.size)の上に楕円の石Ellipse(pos=self.pos, size=self.size)を描画しているだけです。

PutButtonクラスはButtonクラスを継承しており、まだ押した時の処理はありません。
tile_idとして、grid上のどの位置のマスかをインスタンス自身が記憶できるようにしています。

2.マスをタップした時に石を置く

下記の状態まで作成
Sep-06-2020 21-29-16.gif

下記のように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_xput_yにタップされたマスの番号を代入し、turnに現在のターンを代入する。
親のクラス(OthelloGrid)のtileのタップされたマスの場所にturnの値を代入し、put_stoneファンクションを呼び出す。
put_stoneOthelloGridに下記のように作成したファンクションで、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.挟んだ石をひっくり返す(ひっくり返せないマスをタップした場合は何もしない)

下記の状態まで作成
Sep-06-2020 21-50-38.gif

下記のようにOthelloGridクラスにマスの座標と現在のターンを起点にひっくり返すことができる石があるかをチェックするファンクションcan_reverse_checkreverse_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_checkPutButtonクラスがタップされた時に(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.パス機能とゲーム終了時の処理の追加

石を置く場所がなくなった時のパス機能
Sep-06-2020 22-25-46.gif

ゲーム終了時の勝敗判定
Sep-06-2020 22-29-12.gif

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_flagfinish_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.py
from 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/b06e76cf23046a78ba05

Xcodeのコマンドラインツールが入っていない場合は下記コマンドを実行してください。

xcode-select --install

依存関係をインストールする

brew install autoconf automake libtool pkg-config
brew link libtool

Cythonをインストールする

pip install cython

kivy-iosをgit cloneする。

git clone https://github.com/kivy/kivy-ios.git
cd kivy-ios

iOS用の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.xcodeproj

Xcodeで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

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

App Store Connect でアプロードした時 Too many symbol files のかべにぶち当たったときの対処方法

  • dSYM ファイルがいっぱいあることが原因っぽい (この辺は曖昧)
    • 調べると dSYM ファイルをどうにかするっていう対処法ばっかりだったからそう思ってる

調べた感じ2つ

1つ目

プロジェクトファイルの debug information format を DWARF にする
image.png

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/SwiftyBeaver

amrv7(iPhone5, iPhone5c以下の端末)はメインプロジェクトで使用しないので、不要となる。

これでアップロードすればきっとだいじょうぶなはず。

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

[SwiftUI]NavigationViewのnavigationBarTitle位置に画像とテキストを両方入れる方法

実装するもの

SwiftUIにおけるSwiftUI NavigationViewでListからNavigationLinkで遷移する方法ではnavigationBarTitleの使い方についても触れましたが、ここでは以下の実装をする為に必要な方法をご紹介したいと思います。

スクリーンショット 2020-09-06 22.08.11.jpg

Code

ContentView
struct 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)")
                    }
                }      
        }
    }
}

これに加えて
Cmd+Nで新規TitleViewを作成し
スクリーンショット 2020-09-06 22.10.39.jpg

スクリーンショット 2020-09-06 22.10.59.jpg
以下のCodeを追加します。

Code

TitleView
struct 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にあらかじめご用意ください。

スクリーンショット 2020-09-06 22.14.48.jpg

最後に

普段は個人でSwiftUIでアプリを作っています。ハードも作るの大好きです。
よければ下記もチェックして下さい♪
Twitter
https://twitter.com/oka_yuuji
note
https://note.com/oka_yuuji

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

[Swift]Eurekaライブラリのカスタマイズ集

はじめに

Eurekaライブラリとは

Eurekaライブラリとは、iOSの入力フォームなどを、高速かつ簡単に作成するためのライブラリである。
https://github.com/xmartlabs/Eureka

背景

このライブラリの基本的な使い方は様々な記事で紹介されている。しかし、応用的な使い方や、マイナーなカスタマイズは中々載っていなかったため、私自身カスタマイズに苦労した。
私この経験を記事にすることで、皆様のお役に立てるのではないかと思い投稿させて頂いた。

この記事の主なターゲット

  • Eurekaライブラリの基本的な使用方法を知っている方
  • Swift初心者の方
  • Eurekaを使用して間も無く、もっと活用できるようになりたいと感じている方

編集履歴

  • 2020年9月6日(日):本記事を投稿

カスタマイズ集

LabelRowにDisclosure Indicatorを表示

EurekaSample.swift
class EurekaSample: FormViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        form.append(Section() {
            $0.append(LabelRow() {
                $0.cell.accessoryType = .disclosureIndicator
            })
        })
    }

}

LabelRowにSub Titleを追加

EurekaSample.swift
import 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.swift
import 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.swift
import 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.swift
import 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.swift
import 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.swift
import 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
                }
            })
        })
    }

}

おわりに

以上になります。万が一ミスなどをお気づきになりましたら、お手数をおかけしてしまい申し訳ありませんが、コメントなどでお知らせして頂けると助かります。

今後も他のカスタマイズが気付き次第更新したいと思います。

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

[Swift]Eurekaライブラリの応用カスタマイズ集

はじめに

Eurekaライブラリとは

Eurekaライブラリとは、iOSの入力フォームなどを、高速かつ簡単に作成するためのライブラリである。
https://github.com/xmartlabs/Eureka

背景

このライブラリの基本的な使い方は様々な記事で紹介されている。しかし、応用的な使い方や、マイナーなカスタマイズは中々載っていなかったため、私自身カスタマイズに苦労した。
私この経験を記事にすることで、皆様のお役に立てるのではないかと思い投稿させて頂いた。

この記事の主なターゲット

  • Eurekaライブラリの基本的な使用方法を知っている方
  • Swift初心者の方
  • Eurekaを使用して間も無く、もっと活用できるようになりたいと感じている方

編集履歴

  • 2020年9月6日(日):本記事を投稿

カスタマイズ集

LabelRowにDisclosure Indicatorを表示

EurekaSample.swift
class EurekaSample: FormViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        form.append(Section() {
            $0.append(LabelRow() {
                $0.cell.accessoryType = .disclosureIndicator
            })
        })
    }

}

LabelRowにSub Titleを追加

EurekaSample.swift
import 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.swift
import 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.swift
import 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.swift
import 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.swift
import 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.swift
import 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
                }
            })
        })
    }

}

おわりに

以上になります。万が一ミスなどをお気づきになりましたら、お手数をおかけしてしまい申し訳ありませんが、コメントなどでお知らせして頂けると助かります。

今後も他のカスタマイズが気付き次第更新したいと思います。

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

[SwiftUI]NavigationViewでListからNavigationLinkで遷移する方法

今回の記事で実装できるもの

SwiftUIでNavigationViewを使えば本当に簡単に以下の様なものが作れます。

スクリーンショット 2020-09-05 22.03.56.jpg

以下ではこの実装の解説をしていきます。

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でアプリを作っています。ハードも作るの大好きです。
よければ下記もチェックして下さい♪
Twitter
https://twitter.com/oka_yuuji
note
https://note.com/oka_yuuji

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

[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する場合も同様で、全要素が取りうる全組み合わせを網羅させることが容易になります。
  • 可読性はあまりよくないので、「全ての組み合わせを網羅する」ことを最優先にしたい場合以外は避けた方が良い書き方かもしれません。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[SwiftUI]EnvironmentObjectをあるViewに入れるとPreviewができない

SwiftUIでPreviewを使っていたところ、
このようなエラーに遭遇しました。

Thread 1: Fatal error: No observable object of type DataManager found.
A View.environmentObject(_:)

現時点の最新のXcode11.6では、ビルドできるのにプレビューに失敗している時、以下の画像のように表示されます。Diagnosticsを押しても、よくわからないエラーが書かれています。

スクリーンショット 2020-09-06 18.40.25.png

クラッシュログの場所を探す

正確なクラッシュログは

~/Library/Logs/DiagnosticReports

にあります。このディレクトリの中から該当するアプリのクラッシュログを探します。
中身を見てみると、一番初めのエラーを見つけました。

問題のあるコード

Viewの構成は以下のようになっています。
ContentView -> SubView -> ChildView

ContentView.swift
var body: some View {
   SubView()
}
...
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .environmentObject(DataManager())
    }
}

SubView.swift
var 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.swift
struct SubView_Previews: PreviewProvider {
    static var previews: some View {
        SubViewView()
            .environmentObject(DataManager()) //←このViewでは使ってないけどここにもenvironmentObjectを書かないといけない
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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にチェック スクリーンショット 2020-09-05 19.32.19.png この時点でプロジェクト名.entitlementsが追加されます。 中身を見ると スクリーンショット 2020-09-06 11.42.04.png 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になっていました。
スクリーンショット 2020-09-06 12.15.38.png

Push用のCertificateを作成

すでにアプリのIdentifiersが用意されている前提の説明となります。はじめから作る場合はこちらが参考になるかと思います。

  • キーチェーンアクセスより証明書を発行(本番・開発用の計2つ)
  • Identifiersをクリック
  • Push Notifications -> Configure スクリーンショット 2020-09-05 19.21.36.png
  • Development SSL Certificate -> Create Certificate スクリーンショット 2020-09-05 19.22.02.png
  • 先程作った証明書を使ってCertificateを作成
  • 本番も同様に作成
  • ここまで完了するとCertificateに以下2つが作られます
    スクリーンショット 2020-09-06 12.42.37.png

  • これら2つをダウンロード

  • ~.cerをダブルクリックし、キーチェーンに登録

  • それぞれ右クリックし、「〜を書き出す」

FirebaseのCloud MessagingにAPNs証明書を登録

Firebaseコンソールの歯車を押して「プロジェクトを設定」

Settings -> Cloud MessagingからAPNs証明書の項目までスクロールし、先程作った証明書をアップロードします
スクリーンショット 2020-09-06 13.15.03.png

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を実行する際はメインスレッドで行う必要があるということです。メインスレッドでの実行を明示しない場合は以下警告がでます。
スクリーンショット 2020-09-06 16.38.52.png
三つ目はMessagingDelegateの実装です。こちらは実装しなくても通知は受け取れます。
ただ、Firebaseコンソール上での通知送信は開発・本番の区別なく送信されるため、テスト送信をしたい場合にはFCMトークンを指定してテスト送信をする必要がありました。
そのため、本番に送る前に手元で通知内容を確認したい場合にはFCMトークンを取得すると便利です。

FirebaseコンソールからPushを送る

FirebaseコンソールのCloud Messaging -> 通知の作成から新たな通知を発行できます。
スクリーンショット 2020-09-06 16.58.56.png
ここの「テストメッセージを送信」を選ぶと特定の端末に通知を送ることができます。
スクリーンショット 2020-09-06 16.57.36.png
先程のAppdelegate内のデリゲートでプリントしたFCMトークンをここに追加して「テスト」を押せば通知を受け取れます。
テスト送信ができればあとは通知設定をしていき、最後に「公開」を押せば本送信されます。
スクリーンショット 2020-09-06 17.01.00.png

参考

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

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 
               ...
            }
        }
  }

さらに

パラメータ定義〜テスト実行部分をクロージャにとるメソッドとして実装すれば、もっと簡潔に重複を減らす形で実装できると思います。

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

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) 
            }
        }
  }

さらに

パラメータ定義〜テスト実行部分をクロージャにとるメソッドとして実装すれば、もっと簡潔に重複を減らす形で実装できると思います。

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

背景に動画を流す方法

背景に動画を置く方法

画面の背景に動画をリピートで再生させ続けたい場合

viewController.swift
import 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-06 9.21.47.png

ここにファイルを移せば再生される

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

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
    }
}

TodoViewModelProtocolTodoEntityProtocolを新たに定義し、抽象化

SwiftUIのView層はProtocolのみ知っている状態にし、ジェネリクスを用いて差し替え容易に

struct TodoView<T: TodoViewModelProtocol>: View {
    @ObservedObject var viewModel: T

PreviewProviderにモックデータ用のクラスを差し込めば実際のデータ状態に依存されずにプレビュー可能に。
モックデータ用のクラスはシンプルデータを自前で用意するだけで良くなりました。

モッククラスを実行ファイルに同梱したくない場合はPreview Contentフォルダに含めれば、製品版に不要なソースがバンドルされることもないので、とても有能です。

SwiftUIのプレビューはデータ設計にも強力なツール

SwiftUIのプレビューは魅力的で、簡易なビューならすぐプレビューできますが、複雑なユースケースが絡んだ場合に安直に実装してしまうと途端にプレビューしにくくなってしまいます。
これをデメリットに感じてしまう人もいるかもしれませんが、UIフレームワークが実装者に設計を意識させる作り方になっているのだと感じました。

抽象化のメリットを具体例で説明する時に毎回いい例を挙げれずに困っていましたが、今回のサンプルはいい例になるなと思い記事にしました。
UIKitは工夫しないとファットになってしまうアーキテクチャでしたが、SwiftUIは初期構想から実装者を良い設計に導くように作られているんだと思いました。

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

オプショナルへの予想を上回る気遣い

オプショナルの学習メモ

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メソッドとオプショナルの関係なども追記していきます。

参考書籍
「絶対に挫折しない iphoneアプリ開発「超」入門 第8版」

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

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 を作成してください

image

アプリ内課金により購入される商品の設定する

これで設定の下方にあるプラスアイコンをクリックして新しく購入する商品を追加できます。

image

ここでは3種類のアイテムを追加できます:

タイプ 説明
消耗品 (Consumable) アプリ内購入した消耗品は、一度使うとなくなり、再度購入することができます。バーチャルキャットフードといったものです。
非消耗品 (Non-Consumable) 非消耗品は一度購入すると期限が切れることはありません。バーチャルキャットハウスといったものです。
自動更新アイテム (Auto-Renewable) ユーザーは、キャンセルするまで繰り返し請求されます。毎日与えるキャットフードの定期購入といったものです。

新しい項目を追加した後は、詳細事項を入力します。項目のID Product ID、リファレンス名(リファレンスのみに使用)Reference Name、ローカライズ名 Localizations を入力する必要があります。

image

テスト設定の構成

次に、作成したばかりの設定ファイルの内容をXcodeに知らせる必要があります。上部にあるアプリ名をクリックし、Edit Scheme をクリックします。

image

StoreKit Configuration 設定の場所をみつけて、それを作成した構成ファイルの名前に設定します。

image

アプリ内購入のテスト

これで、シミュレーターまたはお使いのデバイスでアプリを実行できます。そして、購入を行うことができます。購入後にXcodeウィンドウにあるこのアイコンの場所を見つけて、それをクリックすると、すべての領収書が表示されます:

screenshot10.png

そのアイコンをクリックすると、今行ったすべての購入が表示されたウィンドウを確認することができます:

image

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

スマホアプリにおける署名について

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.gradle
signingConfigs {
    release {
        keyAlias 鍵エイリアス
        keyPassword 鍵パスワード
        storeFile Key storeファイル名
        storePassword storeパスワード
    }
}

buildTypes {
    release {
        signingConfig signingConfigs.release
    }
}

アプリケーションID

AndroidアプリのIDはAndroid Studioでプロジェクトを作るときに指定します。このIDは、appフォルダのbuild.gradleに設定されます。

app/build.gradle
    defaultConfig {
        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等)につなげたいと思います。

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

はじめての 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)
}

ActionStateinitialState あたりは必須 Reactor プロトコルの定義で必須で、Mutation に関しては、Action をもとに実行される処理の具体的な結果の値を定義します。また、Mutation の定義は必須ではなく、定義が無い場合には ActionMutation として扱われます。

次に 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)
    }

}

基本的には、disposeBagbind(reactor:) の定義が必須になります。今回はサンプル実装なので、reactor への反映をクラス内で行っていますが、本来はより Testable にするために、切り離す必要があります(初回表示の View の場合は、AppDelegate 内で reactor の反映を行うなど)。また、bind(reactor:) が呼ばれるのは、reactor への反映が完了していて、かつ viewDidLoad の後に呼び出されます。ですので、初回時に行う処理などを bind(reactor:)rx.methodInvoked(#selector(viewDidLoad)) のように Observe したいところですが、これは呼ばれないので初回時の処理は Observable.just(Void()) で定義します(参考のIssue-comment)。

こんな感じで、ReactorKit を使ってシンプルにアプリを作成することができました? また他の場面で使用することがあったらまた記事を書きたいと思います。

参考

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