20191215のSwiftに関する記事は17件です。

【Objective-C/Swift】iOS13対応にてUITableViewの「shouldIndentWhileEditingRowAtIndexPath」が機能しなくなった話。

こういう人に向けて発信しています。

・編集モード中に左寄せしていた人
・iOS13対応をしている人
・下記UITableViewDelegateのデリゲートメソッドを使用している人

//インテント調整
- (BOOL)tableView:(UITableView *)tableView shouldIndentWhileEditingRowAtIndexPath:(NSIndexPath *)indexPath{
      //チェックマーク分の余白を詰める為にインテントは表示しない。
      return NO;
}

iOS12以前では編集モードで出てくるチェックボタンを消したい場合は
冒頭に貼りましたコードなどで対応する必要がありました。

そうすればインデント(左からの余白)が無しになり、
詰められるといった算段です。

しかしながら、iOS13でiOSシミュレータ上・実機で確認してみると、
上記コードは全く動作していない事が分かりました。

picture_pc_9eae92328994458c88cdd127d5145a18.png

上記iOSシミュレーターは下記のコードで動かしております。
つまり、インデントを調整するデリゲートメソッドも採用しているにも
関わらずインデントがNOになっておりません。

#import "ViewController.h"

@interface ViewController ()<UITableViewDataSource,UITableViewDelegate>
@property (weak, nonatomic) IBOutlet UITableView *tableView;
@end

@implementation ViewController
- (void)viewDidLoad {
   [super viewDidLoad];
   self.tableView.delegate = self;
   self.tableView.dataSource = self;

       //編集モードを有効にする。
   [self.tableView setEditing:YES animated:YES];
   self.tableView.allowsMultipleSelectionDuringEditing = true;
}

//インテント調整
- (BOOL)tableView:(UITableView *)tableView shouldIndentWhileEditingRowAtIndexPath:(NSIndexPath *)indexPath{
      //チェックマーク分の余白を詰める為にインテントは表示しない。
      return NO;
}

//row = 行数を指定するデリゲートメソッド
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{
   //必ずNSInteger型を返してあげている。
   return 30;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
   //標準で用意されているTableViewを利用する場合。
   NSString *cellIdentifier = @"Cell";
   UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellIdentifier];

   if (!cell) {
       cell = [[UITableViewCell alloc]initWithStyle:UITableViewCellStyleDefault reuseIdentifier:cellIdentifier];
   }
   cell.textLabel.text = [NSString stringWithFormat:@"このセルは%ld番目のセルになります!", (long)indexPath.row];
   return cell;
}

@end

調査結果について

そもそもshouldIndentWhileEditingRowAtIndexPathでTweet検索して、
最新のツイートが2013年とかなのでニッチなデリゲートメソッドです。

なのでiOS13とか以前に関連する文献は一切見つかりませんでした。

特に公式で非推奨になるとアナウンスされたコードでも無いので、
不具合動作なのかなとも考えておりますが、
現時点で編集モード中インデントを無しにするというのは難しいと考えております。

対応策について
・編集モードは制御出来ないと考え使用しない。
・編集モードを自作する。

まずは仕様としては
・汎用性が高いようにセルを複数選択を想定する。
・セルを押下時は背景色を変えて、非選択時には背景色をデフォルトに。

上記仕様で考えて実装してみました。
Objective-C/Swift両方とも書いてみました。

Objective-C版

#import "ViewController.h"

@interface ViewController ()<UITableViewDataSource,UITableViewDelegate>
@property (weak, nonatomic) IBOutlet UITableView *tableView;
@property (strong,nonatomic) NSMutableArray<NSIndexPath *> *selectedIndexPathArray;

@end

@implementation ViewController


- (void)viewDidLoad {
   [super viewDidLoad];
   self.tableView.delegate = self;
   self.tableView.dataSource = self;
   self.selectedIndexPathArray = @[].mutableCopy;
}


- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{
    return 30;
}


- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
   //標準で用意されているTableViewを利用する場合。
   NSString *cellIdentifier = @"Cell";
   UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellIdentifier];

   if (!cell) {
       cell = [[UITableViewCell alloc]initWithStyle:UITableViewCellStyleDefault reuseIdentifier:cellIdentifier];
   }
   cell.textLabel.text = [NSString stringWithFormat:@"このセルは%ld番目のセルになります!", (long)indexPath.row];
   return cell;
}


- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath{
   if([self.selectedIndexPathArray containsObject:indexPath]){
       [self.selectedIndexPathArray removeObject:indexPath];
   }else{
       [self.selectedIndexPathArray addObject:indexPath];
   }
   [self.tableView reloadData];

}



/**
@brief セル表示前の処理
*/
- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath{
   //セルのテキストが入っている場合は色を青にする
   if([self.selectedIndexPathArray containsObject:indexPath]){
       cell.backgroundColor = [UIColor lightGrayColor];
   }else{
       cell.backgroundColor = [UIColor clearColor];
   }
}

@end

Swift版

import UIKit

class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource  {
   @IBOutlet weak var tableView: UITableView!
   var selectedIndexPathArray : NSMutableArray = [] ;
   override func viewDidLoad() {
       super.viewDidLoad()
       // Do any additional setup after loading the view.
       self.tableView.dataSource = self;
       self.tableView.delegate = self;
   }

   func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
       return 30;
   }

   func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
       let cell = UITableViewCell(style: UITableViewCell.CellStyle.default, reuseIdentifier: "Cell")
       // セルに表示する値を設定する
       cell.textLabel!.text = indexPath.row.description
       return cell
   }

   func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
       if self.selectedIndexPathArray .contains(indexPath){
           self.selectedIndexPathArray.remove(indexPath)
       }else{
           self.selectedIndexPathArray.add(indexPath)
       }
       tableView.reloadData()
   }

   func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
       if self.selectedIndexPathArray .contains(indexPath){
           cell.backgroundColor = .blue
       }else{
           cell.backgroundColor = .clear
       }
   }

}

簡単に解説するのであれば、
(1)選択されたNSIndexPathを管理する配列を用意する。
(2)押下時に配列にNSIndexPathを含むか判定し、
   含む場合は削除、含まない場合は追加する。
(3)セル表示前にセル自身のindexPathを判定し、
   含む場合は選択色、含まない場合は透過色(デフォルト)にする。

以上がロジックです。

Objective-C
NSArray *selectedIndexPaths = [self.tableView indexPathsForSelectedRows];

上記のようなコードで最後にTableViewの選択されている全てのセルを
取得すると思いますが、その場合は上記コードの配列をそのまま活用すればOKです。
(押下されたセルのみ、NSIndexPath型の配列なので)

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

Swift5でDropbox APIを使いDropbox上にある画像を表示

導入まではこちら
Dropbox APIをSwift5で使用する方法

バージョン情報

・Swift version 5.0.1
・Xcode version 10.2.1

ダウンロードボタンを設置

ViewController.swift
import UIKit
import SwiftyDropbox

class ViewController: UIViewController {
    //イメージビューを追加
   let myImageView = UIImageView()

    override func viewDidLoad() {
       super.viewDidLoad()
       // Do any additional setup after loading the view.
       //省略

       //ダウンロードボタンを追加
       let downloadButton = UIButton(type: UIButton.ButtonType.system)
       downloadButton.frame = CGRect(x: 10, y: 220, width: 100, height: 30)
       downloadButton.setTitle("Download", for: .normal)
       downloadButton.addTarget(self, action: #selector(self.downloadDropboxFile), for: .touchUpInside)
       self.view.addSubview(downloadButton)

       //画像表示エリアの記載
       myImageView.frame = CGRect(x: 10, y: 500, width: 200, height: 120)
       self.view.addSubview(myImageView)
   }

ダウンロード 画面表示処理

ViewController.swift
@objc func downloadDropboxFile() {
   //ダウンロード処理
   if let client = DropboxClientsManager.authorizedClient {
       //ダウンロード先URLを設定
       let destination : (URL, HTTPURLResponse) -> URL = { temporaryURL, response in
           let fileManager = FileManager.default
           let directoryURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
           let UUID = Foundation.UUID().uuidString
           var fileName = ""
           if let suggestedFilename = response.suggestedFilename {
               fileName = suggestedFilename
           }
           let pathComponent = "\(UUID)-\(fileName)"
           return directoryURL.appendingPathComponent(pathComponent)
       }
       //画面描画処理
       client.files.download(path: "/logo.png", destination: destination).response { response, error in
           if let (metadata, url) = response {
               print("Downloaded file name: \(metadata.name)")
               do {
                   //urlをData型に変換
                   let data = try Data(contentsOf: url)
                   //Data型に変換したurlをUIImageに変換
                   let img = UIImage(data: data)
                   //UIImageをivに変換
                   let iv:UIImageView = UIImageView(image:img)
                   //変換したivをviewに追加
                   self.view.addSubview(iv)
                   //表示位置決定
                   iv.layer.position = CGPoint(x: self.view.bounds.width/2, y: 400.0)
               } catch let err {
                   print("Error : \(err.localizedDescription)")
               }
           } else {
               print(error!)
           }
       }
   }
}

画面

DropBoxサイト上
picture_pc_22a741f1f9a99d5da49ec7bf8a0f6898.png

入れた画像
picture_pc_029190303d86184deef86de18a6f501a.png

動作画面

ダウンロードボタンを押下すると画像が表示される

参考

Download-style request
[Swift] 指定したURLの画像を表示する。

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

Swift 5でDropbox APIを使いDropbox上にある画像を表示

導入まではこちら
Dropbox APIをSwift5で使用する方法

バージョン情報

・Swift version 5.0.1
・Xcode version 10.2.1

ダウンロードボタンを設置

ViewController.swift
import UIKit
import SwiftyDropbox

class ViewController: UIViewController {
    //イメージビューを追加
   let myImageView = UIImageView()

    override func viewDidLoad() {
       super.viewDidLoad()
       // Do any additional setup after loading the view.
       //省略

       //ダウンロードボタンを追加
       let downloadButton = UIButton(type: UIButton.ButtonType.system)
       downloadButton.frame = CGRect(x: 10, y: 220, width: 100, height: 30)
       downloadButton.setTitle("Download", for: .normal)
       downloadButton.addTarget(self, action: #selector(self.downloadDropboxFile), for: .touchUpInside)
       self.view.addSubview(downloadButton)

       //画像表示エリアの記載
       myImageView.frame = CGRect(x: 10, y: 500, width: 200, height: 120)
       self.view.addSubview(myImageView)
   }

ダウンロード 画面表示処理

ViewController.swift
@objc func downloadDropboxFile() {
   //ダウンロード処理
   if let client = DropboxClientsManager.authorizedClient {
       //ダウンロード先URLを設定
       let destination : (URL, HTTPURLResponse) -> URL = { temporaryURL, response in
           let fileManager = FileManager.default
           let directoryURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
           let UUID = Foundation.UUID().uuidString
           var fileName = ""
           if let suggestedFilename = response.suggestedFilename {
               fileName = suggestedFilename
           }
           let pathComponent = "\(UUID)-\(fileName)"
           return directoryURL.appendingPathComponent(pathComponent)
       }
       //画面描画処理
       client.files.download(path: "/logo.png", destination: destination).response { response, error in
           if let (metadata, url) = response {
               print("Downloaded file name: \(metadata.name)")
               do {
                   //urlをData型に変換
                   let data = try Data(contentsOf: url)
                   //Data型に変換したurlをUIImageに変換
                   let img = UIImage(data: data)
                   //UIImageをivに変換
                   let iv:UIImageView = UIImageView(image:img)
                   //変換したivをviewに追加
                   self.view.addSubview(iv)
                   //表示位置決定
                   iv.layer.position = CGPoint(x: self.view.bounds.width/2, y: 400.0)
               } catch let err {
                   print("Error : \(err.localizedDescription)")
               }
           } else {
               print(error!)
           }
       }
   }
}

画面

DropBoxサイト上
picture_pc_22a741f1f9a99d5da49ec7bf8a0f6898.png

入れた画像(Finder上)
picture_pc_029190303d86184deef86de18a6f501a.png

動作画面

ダウンロードボタンを押下すると画像が表示される

参考

Download-style request
[Swift] 指定したURLの画像を表示する。

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

Dropbox APIをSwift5で使用する方法

SwiftでDropbox APIを活用する特殊案件にぶち当たって
Swift特有のバージョン毎に微妙に何かが違う現象でハマったので、メモ。
Pods入れてから認証まで。
Pods部分はもっとわかりやすい人の解説へどうぞ。

バージョン情報

・Swift version 5.0.1
・Xcode version 10.2.1

Xcodeプロジェクトの作成

Single View Applicationのプロジェクトを適当に作成

Cocoapodsの設定

# Uncomment the next line to define a global platform for your project
# platform :ios, '8.0'

target 'dropCheck' do
 # Comment the next line if you don't want to use dynamic frameworks
 use_frameworks!

 # Pods for dropCheck
 pod 'SwiftyDropbox'

end

現バージョンは特に何も指定しなくてOK

Info.plistの設定変更

赤色塗りつぶしは自身のAPIキーを記載
Dropboxアプリの自身のAPIキーはApp Consoleで取得

picture_pc_4a6131796f464794ae782dd69523d687.png

DropboxClientのインスタンス初期化

AppDelegate.swift
import UIKit
import SwiftyDropbox //追加

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

   var window: UIWindow?

   func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
       // Override point for customization after application launch.
       DropboxClientsManager.setupWithAppKey("自分のAPIキー") //追加
       return true
   }

画面にログインするボタンを作成

ViewController.swift
import UIKit
import SwiftyDropbox

class ViewController: UIViewController {

   override func viewDidLoad() {
       super.viewDidLoad()
       // Do any additional setup after loading the view.
       // ログインボタンを追加
       let signInButton = UIButton(type: UIButton.ButtonType.system)
       signInButton.frame = CGRect(x: 10, y: 190, width: 100, height: 30)
       signInButton.setTitle("Sign In",  for: .normal)
       signInButton.addTarget(self, action: #selector(self.signInDropbox), for: .touchUpInside)
       self.view.addSubview(signInButton)
   }
   @objc func signInDropbox(){
       if let _ = DropboxClientsManager.authorizedClient {
           //既にログイン済みだとクラッシュするのでログアウト
           DropboxClientsManager.unlinkClients()
       }
       DropboxClientsManager.authorizeFromController(UIApplication.shared,
                                                     controller: self,
                                                     openURL: { (url: URL) -> Void in
                                                       UIApplication.shared.openURL(url)
       })
   }

認証処理

AppDelegate.swift
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
   if let authResult = DropboxClientsManager.handleRedirectURL(url) {
       switch authResult {
       case .success:
           print("Success! User is logged into Dropbox.")
       case .cancel:
           print("Authorization flow was manually canceled by user!")
       case .error(_, let description):
           print("Error: \(description)")
       }
   }
   return true

一応この3点セットがあればDropbox APIの確認という意味では動く。

動作画面


おわりに

他の言語と違って、
Swiftは調べてもしんどいし、色々な方の解説読みに行くより
結局Gitの英語の説明読みに行ったほうが早く解決する事の方が多い。なんでだろね
そしてそのGitの説明もiOSとかSwiftの修正に追いついていないという現象もついてきて
ダブル、トリプルでハマる…Apple様には逆らえない

参考

・公式
http://dropbox.github.io/SwiftyDropbox/api-docs/latest/
https://github.com/dropbox/SwiftyDropbox/issues/94

・Qiita
SwiftyDropboxでDropboxの簡単なファイル操作をする

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

Dropbox APIをSwiftで使用する方法

SwiftでDropbox APIを活用する特殊案件にぶち当たって
Swift特有のバージョン毎に微妙に何かが違う現象でハマったので、メモ。
Pods入れてから認証まで。
Pods部分はもっとわかりやすい人の解説へどうぞ。

バージョン情報

・Swift version 5.0.1
・Xcode version 10.2.1

Xcodeプロジェクトの作成

Single View Applicationのプロジェクトを適当に作成

Cocoapodsの設定

# Uncomment the next line to define a global platform for your project
# platform :ios, '8.0'

target 'dropCheck' do
 # Comment the next line if you don't want to use dynamic frameworks
 use_frameworks!

 # Pods for dropCheck
 pod 'SwiftyDropbox'

end

現バージョンは特に何も指定しなくてOK

Info.plistの設定変更

赤色塗りつぶしは自身のAPIキーを記載
Dropboxアプリの自身のAPIキーはApp Consoleで取得

picture_pc_4a6131796f464794ae782dd69523d687.png

DropboxClientのインスタンス初期化

AppDelegate.swift
import UIKit
import SwiftyDropbox //追加

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

   var window: UIWindow?

   func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
       // Override point for customization after application launch.
       DropboxClientsManager.setupWithAppKey("自分のAPIキー") //追加
       return true
   }

画面にログインするボタンを作成

ViewController.swift
import UIKit
import SwiftyDropbox

class ViewController: UIViewController {

   override func viewDidLoad() {
       super.viewDidLoad()
       // Do any additional setup after loading the view.
       // ログインボタンを追加
       let signInButton = UIButton(type: UIButton.ButtonType.system)
       signInButton.frame = CGRect(x: 10, y: 190, width: 100, height: 30)
       signInButton.setTitle("Sign In",  for: .normal)
       signInButton.addTarget(self, action: #selector(self.signInDropbox), for: .touchUpInside)
       self.view.addSubview(signInButton)
   }
   @objc func signInDropbox(){
       if let _ = DropboxClientsManager.authorizedClient {
           //既にログイン済みだとクラッシュするのでログアウト
           DropboxClientsManager.unlinkClients()
       }
       DropboxClientsManager.authorizeFromController(UIApplication.shared,
                                                     controller: self,
                                                     openURL: { (url: URL) -> Void in
                                                       UIApplication.shared.openURL(url)
       })
   }

認証処理

AppDelegate.swift
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
   if let authResult = DropboxClientsManager.handleRedirectURL(url) {
       switch authResult {
       case .success:
           print("Success! User is logged into Dropbox.")
       case .cancel:
           print("Authorization flow was manually canceled by user!")
       case .error(_, let description):
           print("Error: \(description)")
       }
   }
   return true

一応この3点セットがあればDropbox APIの確認という意味では動く。

動作画面


おわりに

他の言語と違って、
Swiftは調べてもしんどいし、色々な方の解説読みに行くより
結局Gitの英語の説明読みに行ったほうが早く解決する事の方が多い。なんでだろね
そしてそのGitの説明もiOSとかSwiftの修正に追いついていないという現象もついてきて
ダブル、トリプルでハマる…Apple様には逆らえない

参考

・公式
http://dropbox.github.io/SwiftyDropbox/api-docs/latest/
https://github.com/dropbox/SwiftyDropbox/issues/94

・Qiita
SwiftyDropboxでDropboxの簡単なファイル操作をする

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

MVVMとMVPパターン

イントロダクション

アプリを作る際に考えることは色々ありますが、その中でも特に重要なのが設計パターンの選定だと思います。
私自身は今までMVVMパターンでの実装が主だったのですが、MVPについて前から興味があったのでサンプルアプリを作りながら両者のメリデメを考察してみました。

参考のためiOSアプリ設計パターン入門を読みました。各章の内容が非常に簡潔にわかりやすくまとまっており、読みやすいためかなりオススメな著書です。

MVVMとは

一言で言うとGUIの構造をModel, View, ViewModelの3つに分けたパターンです。
それぞれの役割は以下です。

  • Model - 表示に関わらないドメインロジックを持つ
  • View - ユーザー操作を受けつけ、ViewModelの状態の変更を監視し画面の更新を行う
  • ViewModel - 画面の表示全般に関わるロジックを持ち、Viewに使うデータを保持する

MVVM Component.png
図のように参照関係は一方向で、逆の参照関係はありません。Model, ViewModelの変更はイベントにより通知されます。

MVVMパターンの大きな特徴としてはViewの更新をデータバインドによって行うという点です。
手続き的ではなく宣言的にロジックが表現できるため、コンポーネント間を疎結合に保てます。それによって可読性やテスト容易性を向上させます。
このデータバインド機構にRxSwiftやReactiveSwitといったライブラリを利用するのですが、こちらの概念理解にかかるコストが非常に高いというのも特徴の一つです。

MVPとは

こちらも一言で言うとGUIの構造をModel, View,Presenterの3つに分けたパターンです。
Modelの役割はMVVMと変わりません。

  • Model - 表示に関わらないドメインロジックを持つ
  • View - ユーザー操作を受けつけ、Presenterに処理を委譲。Presenterから画面更新の指示を受け付け、画面を更新する
  • Presenter - ModelとViewの仲介役。画面表示に関するロジックを持つ

MVP Component (1).png
Viewの更新にはPassive ViewとSupervising Controllerの2つの方法がありますが、今回は図のPassive Viewを前提にします。Passive Viewはデータフローが追いやすい反面、簡単な処理もModelが絡んだ際は必ず以下のフローに準拠する必要があります。

View → Presenter → Model → Presenter → View

そのため、コードが冗長になりやすいという面もあります。
しかし、全てのプレゼンテーションロジックをPresenterの中に閉じ込めることができるのでテスト容易性が向上します。

MVVMとMVPの比較

まず、MVVMもMVPも共通して言えることはコンポーネントを疎結合にしてテスト容易性や可読性を向上させています。
そもそもなぜそうする必要があるのか。
それはAppleが提唱したCococa MVCパターンにてViewControllerの役割が大きすぎて可読性、テスト容易性が低くなってしまい、いわゆるFat View Controllerになりがちということがあります。
そのため、MVVMやMVPなどのパターンを使い、責務の分離をすることでそれらの課題を解決しています。

MVVMとMVPの違いを実感するために冒頭で紹介した著書のサンプルコードを元にMVVMとMVPそれぞれの設計でサンプルアプリを作ってみました。
Githubのユーザーを検索し、ユーザーのリポジトリ一覧を表示するものです。
データは実際のAPI通信は行わず、モックから取得しています。

ユーザー検索 リポジトリを表示

本記事ではsearchBarに文字を入力して対象のユーザーを表示する部分をMVVM, MVPそれぞれのパターンで実装する部分を解説します。リポジトリの表示についてはサンプルコードのURLを最後に記載しますので、興味を持たれた方は見て頂けますと幸いです。

まずはMVPでの実装を見てみます。
Presenterの実装ではまずInput/Outputの処理をプロトコルで宣言しておきます。
ViewControllerから委譲されたInputの処理を行い、OutputにてModelに処理を委譲し、結果を再びViewControllerに伝えます。

// SearchUserPresenter
protocol SearchUserPresenterInput {
    var numberOfUsers: Int { get }
    func user(forRow row: Int) -> User?
    func didSelectRow(at indexPath: IndexPath)
    func didTapSearchButton(text: String?)
}

protocol SearchUserPresenterOutput: AnyObject {
    func updateUsers(_ users: [User])
    func transitionToUserDetail(userName: String)
}

final class SearchUserPresenter: SearchUserPresenterInput {

    ...

    func didTapSearchButton(text: String?) {
        guard let query = text { return }
        model.fetchUser(query: query) { [weak self] result in
            switch result {
            case .success(let users):
                self?.users = users
                DispatchQueue.main.async {
                    self?.view.updateUsers(users)
                }
            case .failure:
                ()
            }
        }
    }

...

}

ViewControllerはOutputプロトコルを実装することで画面を更新します。

// SearchUserViewController.swift

extension SearchUserViewController: SearchUserPresenterOutput {
    func updateUsers(_ users: [User]) {
        tableView.reloadData()
    }

    ...

}

これによって先ほど説明した以下フローを実現しています。

View → Presenter → Model → Presenter → View

次にMVVMでの実装を見てみます。
ViewModelはViewからの入力に反応してstate(Mutable Property)を更新します。
ここではViewからsearch()メソッドが実行(入力)されることによってModelがデータ取得処理を行い、結果をstateにバインドしています。

final class SearchUserViewModel {
    let state = BehaviorRelay<[User]?>(value: nil)
    private let model: SearchUserModel
    private let disposeBag = DisposeBag()

    init(model: SearchUserModel) {
        self.model = model
    }

    func search(query: String) {
        model.fetch(query: query)
            .asObservable()
            .bind(to: state)
            .disposed(by: disposeBag)
    }
}

Viewでは予め監視対象を設定しておき、ViewModelのイベント通知によって画面更新を行います。

final class SearchUserViewController: UIViewController {

    ...

    override func viewDidLoad() {
        super.viewDidLoad()

        viewModel = SearchUserViewModel(model: SearchUserModel())
        tableView.registerCell(type: SearchUserCell.self)

        viewModel.state.subscribe(onNext: { [unowned self] _ in
            self.tableView.reloadData()
        }).disposed(by: disposeBag)
    }
}

...

extension SearchUserViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        guard let state = viewModel.state.value else { return 0 }
        return state.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueCell(type: SearchUserCell.self, indexPath: indexPath)
        guard let state = viewModel.state.value else { return cell }
        cell.configure(user: state[indexPath.row])
        return cell
    }
}

MVVMとMVPそれぞれで実装してみての所感

まず、最初に感じたのはMVPではプレゼンテーションロジックの実装を行う際に、「ViewとModelそれぞれのことを考えなければならない。」ということが気になりました。
先に説明した通りPresenterは「仲介役」です。
そのため、Presenterを実装する際にはViewとModelそれぞれの顔色を伺いながら実装するということを余儀なくさせられます。
一方で、MVVMはプレゼンテーションロジックを実装する際にはあくまでModelからとってきたデータをどのように整形するのかのみに集中できました。
コードを見てもらえばわかる通り、コード量も段違いにMVPの方が多いです。
これは先に説明した通りコードが冗長になるということを体現しています。

まとめ

今回実際にMVVM, MVPパターンでサンプルアプリを実装してみてそれぞれのメリット・デメリットを比較したものは以下でした。

MVVM

メリット
  • View, ViewModel, Modelが一方向の参照関係になっているため、疎結合が保て、それぞれの作業に集中できる
  • それぞれのコンポーネントで作業分担が明確なため、冗長なコードが生まれず、コード量を比較的少なくできる
デメリット
  • RxSwift, ReactiveSwiftなどのライブラリが必須なため、初期設定が手間
  • FRPの概念を理解するまでの学習コストが高い

MVP

メリット
  • 初期導入に必要なものがない
  • 特別な実装が不要なため、すでにMVCなどで実装しているプロジェクトにも導入がしやすい
デメリット
  • データフローへの準拠が厳しく、コードが冗長になりやすい
  • コード量が増え、実装に手間がかかる

サンプルコード

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

AWS Kinesis Video StreamsでビデオストリーミングWebRTCを実施

こんにちは フリーランスの永田です。

今年も仕事をしながら、技術を探求して、ARのライブラリー、ビデオ編集のライブラリー、背面前面同時撮影できるカメラのライブラリーなどを作成しました。 こちらに技術を公開しています

今回はSwift愛好会の2019年のアドベントカレンダーに参加させていただきました。

AWS Kinesis Video StreamsでビデオストリーミングWebRTCを実施します。

こちらの順序で展開させていただきます。

環境

サンプルソース

やり方

つまづきポイント

挙動

環境 

macOS Catalina 10.16.2

Xcode Version 11.2.1 (11B500) 

AWS マネジメントコンソールに入れること(AWSに登録済み)

AWS Kinesis Video Streamsでシグナリングチャンネルの作成

サンプルソース

このリンク先のアプリとAWS Kinesis Video Streamsを連携させてWebRTCを実現します。
https://github.com/awslabs/amazon-kinesis-video-streams-webrtc-sdk-ios

やり方

サンプルソースのReadMeを確認願います。

Amazon Cognitoのユーザープール、IDプールの管理設定が必要になります。

Amazon Cognitoの作成方法、サンプルの確認手順はこのリンクに書いています。

AWSKinesisVideoWebRTCDemoApp.xcworkspaceをbuild&Run

buildが通らない場合、サンプルソースにbuildを通すコマンド操作を確認。


記載箇所にAmazon Cognitoで設定した項目を記載します。

Constants.swift

import Foundation
import AWSCognitoIdentityProvider
// 記入
let CognitoIdentityUserPoolRegion = AWSRegionType.APNortheast1
// 記入
let CognitoIdentityUserPoolId = "Amazon Cognitoのプール ID"
// 記入
let CognitoIdentityUserPoolAppClientId = "Amazon Cognitoのアプリクライアント ID"
// 記入
let CognitoIdentityUserPoolAppClientSecret = "Amazon Cognitoのアプリクライアントのシークレット"

let AWSCognitoUserPoolsSignInProviderKey = "UserPool"
// 記入
let CognitoIdentityPoolID = "ap-northeast-1:~~~~~~~~~~~~~ ID プールの管理から作成するID プールID"
// 記入
let AWSKinesisVideoEndpoint = "https://kinesisvideo.ap-northeast-1.amazonaws.com"
let AWSKinesisVideoKey = "kinesisvideo"

let VideoProtocols =  ["WSS", "HTTPS"]

let ConnectAsMaster = "connect-as-master"
let ConnectAsViewer = "connect-as-viewer"

let MasterRole = "MASTER"
let ViewerRole = "VIEWER"

let ClientID = "ConsumerViewer"

awsconfiguration.json

{
    "Version": "1.0",
    "CredentialsProvider": {
        "CognitoIdentity": {
            "Default": {
          // 記入
                "PoolId": "ap-northeast-1:~~~~~~~~~~~~~ ID プールの管理から作成するID プールID",
          // 記入
                "Region": "ap-northeast-1"
            }
        }
    },
    "IdentityManager": {
        "Default": {}
    },
    "CognitoUserPool": {
        "Default": {
       // 記入
            "AppClientSecret": "Amazon Cognitoのアプリクライアントのシークレット",
       // 記入
            "AppClientId": "Amazon Cognitoのアプリクライアント ID",
       // 記入
            "PoolId": "Amazon Cognitoのプール ID",
       // 記入
            "Region": "ap-northeast-1"
        }
    }
}

  • アプリ画面のサインアップ画面でユーザー登録
    IMG_0C39895C7541-1.jpeg

  • 検証コードと新しいパスワードを登録

パスワードを再設定の際、Amazon Cognitoでメール、SMSからパスワードの再設定コードを受け取る必要があります。

スクリーンショット 2019-12-15 20.12.19.png
スクリーンショット 2019-12-15 20.12.44.png

  • サインインしてConnect画面に遷移

Kinesis Video Streamsのシグナリングチャンネルのチャンネル名
Amazon Cognitoのアプリクライアントのシークレット
リージョン名
を記入して、青い色Button(どちらでも良い)を押下で成功するとConnectedになり、RTC専用のカメラプレビュー画面に遷移します。AWSのライブラリーではカメラもライブラリー内で使用されていて、UIViewを渡す処理になっていますので、カメラの種類をカスタマイズは難しいようです。

モバイルで表示しているプログラムです。ライブラリーのRTCMTLVideoViewはUIViewになります。
スクリーンショット 2019-12-16 12.06.26.png

    override func viewDidLoad() {
        super.viewDidLoad()

        #if arch(arm64)
        // Using metal (arm64 only)
        let localRenderer = RTCMTLVideoView(frame: localVideoView?.frame ?? CGRect.zero)
        let remoteRenderer = RTCMTLVideoView(frame: view.frame)
        localRenderer.videoContentMode = .scaleAspectFill
        remoteRenderer.videoContentMode = .scaleAspectFill
        #else
        // Using OpenGLES for the rest
        let localRenderer = RTCEAGLVideoView(frame: localVideoView?.frame ?? CGRect.zero)
        let remoteRenderer = RTCEAGLVideoView(frame: view.frame)
        #endif

        webRTCClient.startCaptureLocalVideo(renderer: localRenderer)
        webRTCClient.renderRemoteVideo(to: remoteRenderer)

        if let localVideoView = self.localVideoView {
            embedView(localRenderer, into: localVideoView)
        }
        embedView(remoteRenderer, into: view)
        view.sendSubviewToBack(remoteRenderer)
    }

つまづきポイント

ユーザーは管理画面から作成ではなく、アプリのサインアップから作成してください。
管理画面から作成すると、パスワードの再設定をしていない状態になってしまい、別途コマンド操作でパスワードの再設定が必要になりますがAWS CLIから操作する場合に、シークレットキーを外す対応が必要です。
今回のサンプルの手順ではAWS CLIから対応するより、アプリのAPIから対応した方が良さそうです。
https://qiita.com/noobar/items/6615501b035e47792227

issuesを投げたところ、デバッグでレスポンスの確認をしてくださいとの事で、確認しましたら、パスワードの再設定的なログがでたので、アプリ側から操作してパスワードを再設定したところ、アプリ認証が通りました。
https://github.com/awslabs/amazon-kinesis-video-streams-webrtc-sdk-ios/issues/1

スクリーンショット 2019-12-15 20.05.02.png

挙動

モバイル

シグナリングチャンネルのConnect画面

IMG_1641292D2A90-1.jpeg

プレビュー画面

IMG_36423BE3E0A2-1.jpeg

AWS Kinesis Video Streams シグナリングチャンネル画面
Chromeなどで開いて下さい。 
CloudWatch メトリクスなどで使用解析もできるので、価格プランなども検討できると思います。
スクリーンショット 2019-12-15 20.23.34.png

複数端末での挙動

アプリケーションを動作させる上で、合計工数は4時間ぐらいで確認できました。

リアルタイム配信が身近なものになったと思いますが、カメラ機能などカスタマイズ性を向上させるのはRTC側を自作する必要があり、Safariだとメディア再生ビューワーが開かなかったので、自作してSafariを開かせる必要が出てきそうです。

関連資料 JSのWeb画面でビデオストリーミングWebRTCを利用する場合

今後も精進して、技術力を高めて、ゆくゆくは技術で還元していきます。

以上、貴重なお時間お読み下さいまして、誠にありがとうございます。

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

プロジェクト起動したら画面真っ黒[Swift][Info.plist configuration "Default Configuration" for UIWindowSceneSessionRoleApplication contained UISceneDelegateClassName...]

状況

プロジェクトをビルドしたら

Info.plist configuration "Default Configuration" for UIWindowSceneSessionRoleApplication contained UISceneDelegateClassName key, but could not load class with name "TinderSwipeSample.SceneDelegate".
[WindowScene] There is no scene delegate set. A scene delegate class must be specified to use a main storyboard file.

こんなエラーとともに実機の画面が真っ黒に。。。。

  • is initial viewcontrollerは全てチェック入れている
  • TARGETS->Deployment info->Main InterfaceStoryboardを変更しても真っ黒なまま

原因

SceneDelegate.swiftファイルを消去してしまっていた
->info.plistConfiguration NameDefault Configurationにしている場合は消去してはいけないらしい
->以前は消去しても実行できていたのでverが新しいとこのバグが起きるかもしれません。はっきりとはわかりません

SceneDelegate.swiftはSwiftUIで使うもの->Storyboardしか使わないから消してもいいやーと思っていました。皆さんも気をつけて。
(エラー文にはscene delegateがないよーみたいなこと書いてくれてるんですがね。。。)

参考

SwiftUIを触って分かったこと:①初期画面の設定方法 - Qiita

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

うちはサスケが習得するのに1ヶ月かかった千鳥を2日で習得してみた。

初めに

この記事は漫画「NARUTO」に大きく依存していますので「NARUTO」知らない方はごめんなさい。
遡ること二日前、Twitterでニジゲンノモリというところで「NARUTO」のアトラクションがあるということを知りました。
以下ニジゲンノモリURL
http://nijigennomori.com/naruto_shinobizato/
行ってみたいなーと思いましたが、就活とか授業で忙しいし金もないし行けませんでした。
どうしようかなあと思った矢先、ニジゲンノモリのアトラクションの一つでAR使って千鳥とか火遁とかを出してる風な画像が撮れるというものがあることに気づきます。
あ、これ作れば行かなくても実質行ったことになるやん!(謎理論)
そう思い開発が始まりました。
千鳥とは
https://w.atwiki.jp/aniwotawiki/pages/17081.html

妥協点

  • 人は殺せない
  • 発動しても同じ体勢から動くことができない
  • なんか千鳥が平べったい

開発スケジュール

1日目
1. Mayaで千鳥っぽい3Dオブジェクトを作る。
2. 断念する。
2日目
1. 絵を描く。
2. コードを書く。
3. 術を発動する。

1日目

まずAR上に表示させる千鳥を作り始めました。
3Dモデルを表示させようとMayaで制作しました。
できたもの
IMG_5913.JPG
僕にはMayaは使いこなせませんでした。
Mayaに詳しい友達に聞いてみたりしましたが作るのが難しいらしく(正しくは面倒くさい)、3Dオブジェクトは断念することにしました。(誰か作って!)
その結果、1日目はほぼコードは書かず3Dオブジェクトも作れず終了。
もっと早く3Dオブジェクト諦めればよかった!!!!!!!

2日目

3Dオブジェクトは断念し画像アニメーションで千鳥することにしました。
この時点でかなりニジゲンノモリのARの劣化になってしまったのですが交通費がないと思えばまあこれでもいいと自分に言い聞かせました。
ということで自分で絵を描いてみました。絵は普段描かないのでクッソ時間かかりました。友達に協力してもらったりしてできた画像が下の4つ
Chidori2.png
Chidori1.png
Chidori3.png
Chidori4.png
こいつらをアニメーションさせることに決定。
下手ですがまあ初心者なので許してください。
ということで、ここまででだいたい1日半くらい終わってしまってます。
ここまで来てやっとコードを書くことができました。

viewController.swift
import UIKit
import SceneKit
import ARKit

class ViewController: UIViewController, ARSCNViewDelegate {

    @IBOutlet var sceneView: ARSCNView!
    let count = 0
    var chidoriNode1: SCNNode!
    var chidoriNode2: SCNNode!
    var chidoriNode3: SCNNode!
    var chidoriNode4: SCNNode!
    var rand: Int = 1
    override func viewDidLoad() {
        super.viewDidLoad()

        // Set the view's delegate
        sceneView.delegate = self

        // Show statistics such as fps and timing information
        sceneView.showsStatistics = true
        //千鳥をUImageとして取り込みそれぞれノードにして変数に入れる。
        chidoriNode1 = (setImageToScene(image: UIImage(named:"Chidori1.png")!))
        chidoriNode2 = (setImageToScene(image: UIImage(named:"Chidori2.png")!))
        chidoriNode3 = (setImageToScene(image: UIImage(named:"Chidori3.png")!))
        chidoriNode4 = (setImageToScene(image: UIImage(named:"Chidori4.png")!))
        Timer.scheduledTimer(timeInterval: 0.05, target: self, selector: #selector(activationChidori), userInfo: nil, repeats: true)
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        // Create a session configuration
        let configuration = ARWorldTrackingConfiguration()

        // Run the view's session
        sceneView.session.run(configuration)
    }
    //画像の大きさとか設定するそしてノードにする
    private func createPhotoNode(_ image: UIImage, position: SCNVector3) -> SCNNode {
        let node = SCNNode()
        let scale: CGFloat = 1.2
        let geometry = SCNPlane(width: image.size.width * scale / image.size.height,
                                height: scale)
        geometry.firstMaterial?.diffuse.contents = image
        node.geometry = geometry
        node.position = position
        return node
    }
    //ノードを返す
    private func setImageToScene(image: UIImage) -> SCNNode {
        var node: SCNNode!
        if let camera = sceneView.pointOfView {
            // カメラから見た座標を設定。
            let position = SCNVector3(x: 0, y: -0.2, z: -2.0)
            let convertedPosition = camera.convertPosition(position, to: nil)
            //大きさとか画像を設定してノードにする。
            node = createPhotoNode(image, position: convertedPosition)
            return node
        }
        return node
    }
    //0.05秒ごとに千鳥を発動する
    @objc func activationChidori() {
        //前回表示された千鳥を消す
        sceneView.scene.rootNode.enumerateChildNodes{ (existingNode, _) in
            existingNode.removeFromParentNode()
        }
        var newRand = Int.random(in:1...4)
        if(rand == newRand) {
            newRand+=1
        }
        rand = newRand
        //ランダムに千鳥を発動する。
        switch rand {
        case 1:
            self.sceneView.scene.rootNode.addChildNode(chidoriNode1)
        case 2:
            self.sceneView.scene.rootNode.addChildNode(chidoriNode2)
        case 3:
            self.sceneView.scene.rootNode.addChildNode(chidoriNode3)
        case 4:
            self.sceneView.scene.rootNode.addChildNode(chidoriNode4)
        case 5:
            self.sceneView.scene.rootNode.addChildNode(chidoriNode1)
        default:
            self.sceneView.scene.rootNode.addChildNode(chidoriNode1)
        }

    }
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)

        // Pause the view's session
        sceneView.session.pause()
    }


}

case分や画像読み込みが長ったらしくて、for文とかでまとめれそうですが、for文の中でUIImageを取得しようとするとなぜか取得結果がnilになってしまうのでシャーナシこんな形になってしまいました。
誰か原因分かる人コメントください。
要は0.05秒ごとにさっきの画像4枚をランダムに表示させてアニメーションっぽくしてます。
この方の記事がとても参考になりました。ありがとうございます。
https://qiita.com/k-boy/items/e18e74e8b341f5d5675d

結果

千鳥発動できました。

終わりに

雷のクオリティが低いとかいう人は僕に3Dオブジェクト提供してください。.daeファイルに変換してもらえると助かります。
絵とかMayaとか触ってたらコードを書いていたのは実質2時間くらいでした。もう2度とMayaなんて触りたくないです。
まあ2日で千鳥を習得できたので「NARUTO」の世界では火影くらい余裕で慣れてしまうかもしれませんね。

補足

  • 作者は厨二病ではありません。
  • 20歳の大学生が一人で家で千鳥をしてても悲しくなんてありません。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

RxSwiftでUXを考慮した(Gmailアプリライクな)詳細画面を実装する

この記事は、Classi Advent Calendar 2019 の15日目の記事です。

こんにちは、ClassiのiOSアプリエンジニアの@yoko-yanです。
Classiは、サーバーサイド、特にRubyエンジニアが多くて、サーバーサイドの記事が多いと思うんですが、アプリも負けずに書いていきたいと思っているので、よろしくお願い致します。
最近は社内でRxSwift勉強会を開催するなど、社内のRxSwiftの啓蒙活動を行っています。

そういったこともあり、せっかくなので、RxSwiftで試行錯誤して作った部分を公開できる範囲で記事にしてClassiのアプリも、しっかり作っているぞ的なイメージを持たせられればと思っています。

UX(ユーザーエクスペリエンス)を考慮した詳細画面とは

さて、現在開発に携わっているアプリの中では、いろいろな工夫を施しているんですが、その中でも、UXの体験向上として、工夫した箇所があるんですが、一覧画面と詳細画面という構成の中で、詳細の前後のページにアクセスしやすくしたいので、詳細画面で左右にスワイプすることで、次の詳細データや前のの詳細データを表示する体験があります。

これは、Gmailアプリが実装しているような、各メールの詳細ビューで左右にスワイプすることで、次の前のメールを表示する方法をイメージしてもらえれば、分かりやすいかと思います。

ezgif.com-resize.gif

記事の内容について

前述したものを実現すべく、RxSwfitを使って、どんな風に実装したかを、記事にしたいと思います。
今回、実際に動かせるソースをGithubに公開できるとよかったんですが、時間の都合で用意できなかったので
別途、公開できるタイミングがあれば、公開したいなと思うので、今回は、ご了承ください。

具体的な実装の解説

アプリのよくある実装として、一覧画面では、一覧データ取得のAPIを叩いて、あらかじめ決められた数(例えば20件など)を取得して一覧画面を作成していると思うので、Gmailのように一覧画面から詳細画面に遷移し、そこでスワイプで前後のデータに遷移できるように実装するには

1. スワイプで遷移した際のデータの切れ目で、ページングのAPIリクエストを実行
2. 追加で取得したデータを一覧ページとスワイプ画面で同期を取る

という実装が必要になります。

これを、RxSwiftで実装してみます。

まず、スワイプでのページングを実装するにあたり、UICollectionViewだったり、UIPageViewControllerだったりで、いろいろ実装方法はあるかと思いますが、今回は、UIPageViewControllerで実装しました。
その理由は、すでに詳細画面をUIViewControllerとして実装していたので、それをそのまま使いたく、UIPageViewControllerなら、それを呼び出すだけでよかったのと、スワイプのいらない詳細画面は、直接、詳細画面のUIViewControllerを呼び出すことが出来るようにしたかったからです。

概要

めちゃくちゃ雑ですが、ざっくり言うと、こんな感じで、ストリームを共有しています。
UX体験 (1).png

PageViewControllerについて

まず、PageViewControllerをどのように実装しているのかを見ていきます。

Item.swift
struct Item: Decodable {
    let id: String
    let name: String
}

このアプリでは、上記のようなStructを扱うとして、以下のように実装しています。

PageViewController.swift
import UIKit
import RxSwift
import RxCocoa

class PageViewController: UIPageViewController {
    private let paginationRequestTrigger: PublishRelay<Void>
    private var items: [Item] = []
    let disposeBag = DisposeBag()

    init(itemsStream: Observable<[Item]>, paginationRequestTrigger: PublishRelay<Void>, selectedItem: Any) {
        self.paginationRequestTrigger = paginationRequestTrigger
        super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)

        dataSource = self
        delegate = self

        let binder = Binder(self) { (vc, items: [Item]) in
            if vc.items.isEmpty {
                guard let index = items.firstIndex(where: { $0.id == selectedItem.id }) else { return }
                vc.items = items
                vc.setupPageViewController(initialIndex: index)
            } else if !items.isEmpty {
                vc.items = items
            }
        }

        itemsStream
            .filter { !$0.isEmpty }
            .bind(to: binder)
            .disposed(by: disposeBag)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError()
    }
}

private extension PageViewController {
    func setupPageViewController(initialIndex: Int) {
        setViewControllers([createDetailPageViewController(of: initialIndex) ?? UIViewController()], direction: .forward, animated: true, completion: nil)
    }

    func createDetailPageViewController(of index: Int) -> UIViewController? {
        guard items.indices.contains(index) else { return nil } // index out of range
        let item = items[index]
        let vc = DetailViewController()
        vc.item = item
        vc.view.tag = index
        return vc
    }
}

extension PageViewController: UIPageViewControllerDataSource {
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        let currentIndex = viewController.view.tag
        return createDetailPageViewController(of: currentIndex - 1)
    }

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        let currentIndex = viewController.view.tag
        return createDetailPageViewController(of: currentIndex + 1)
    }
}

extension PageViewController: UIPageViewControllerDelegate {
    func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
        guard let nextIndex = pendingViewControllers.first?.view.tag else { return }
        guard items.count - 1 == nextIndex else { return }
        paginationRequestTrigger.accept(())
    }
}

このPageViewControllerは、一覧画面で、セル(アイテム)をクリックした際に、インスタンス化されて呼び出されます。
このPageViewControllerでやってるRxSwiftを活用した実装のポイントとしては

  • (1) 一覧取得APIを実行しているストリームを購読し、ListViewController(一覧画面)とデータを同期する
  • (2) スワイプ時に表示するデータがない場合、ストリームを実行し、追加取得する

の2つです。

(1) 一覧取得APIを実行しているストリームを購読し、ListViewController(一覧画面)とデータを同期する

インスタンス生成時に一覧画面から、一覧取得APIを実行しているストリームと、そのトリガーと、選択されたデータを渡しています。

init(itemsStream: Observable<[Item]>, paginationRequestTrigger: PublishRelay<Void>, selectedItem: Any) {
引数               説明                             
itemsStream        一覧取得APIを実行し、 itemの配列が流れるストリーム
paginationRequestTrigger itemsStreamを現在のページ指定を元に追加データを取得するトリガー
selectedItem 一覧画面で選択したセルのデータ

以下の箇所で、ストリームに値が流れてきた際に、必要に応じてDetaiViewControllerを再セットします。

let binder = Binder(self) { (vc, items: [Item]) in
    if vc.items.isEmpty {
        guard let index = items.firstIndex(where: { $0.id == selectedItem.id }) else { return }
        vc.items = items
        vc.setupPageViewController(initialIndex: index)
    } else if !items.isEmpty {
        vc.items = items
    }
}

itemsStream
    .filter { !$0.isEmpty }
    .bind(to: binder)
    .disposed(by: disposeBag)

(2) スワイプ時に表示するデータがない場合、ストリームを実行し、追加取得する

スワイプで次々にデータを表示する際、DetailViewControllerを生成して表示していますが、元になるデータは、インスタンス生成時に、initで渡されたデータなので、スワイプを続けているとデータの切れ目に辿りつきます。
そのタイミングで、一覧取得APIを実行して、新たにデータを追加取得したいのですが、ここで取得したデータは、一覧でも反映して(同期を取りたい)、一覧画面に戻った時に、同じストリーム実行せずに済ませたいので、initで渡されたトリガーを元に、一覧画面側のストリームを実行し、 (1)の箇所で、結果を反映させます。

extension PageViewController: UIPageViewControllerDelegate {
    func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
        guard let nextIndex = pendingViewControllers.first?.view.tag else { return }
        guard items.count - 1 == nextIndex else { return }
        paginationRequestTrigger.accept(())
    }
}

このように実装することで、一覧画面と同じストリームや同じパラメータセットのAPIを、わざわざ実行せずに済むようにしました。

ListViewControllerについて

一覧画面を表示するListViewControllerについて、実装を見ていきます。

[補足]
余談ですが、このアプリの実装は、MVVM+RxSwiftで実装しているので、この記事でも、それで実装しているコードで記述します。
また、MVVM+RxSwiftで実装するにあたっては、以下の書籍を参考にさせて頂いています。
RxSwift研究読本3 ViewModel設計パターン入門
この書籍の中のsergdort氏のViewModelパターンを採用しています。

ListViewControllerは、以下のように実装しています。

ListViewController.swift
final class ListViewController: UIViewController {
    private let disposeBag = DisposeBag()
    private let viewModel = ListViewModel()
    private let dataSource = DataSource()

    @IBOutlet private weak var tableView: UITableView!

    private let reloadRequestTrigger = PublishRelay<Void>()
    private let paginationRequestTrigger = PublishRelay<Void>()
    private let changeDataTrigger = PublishRelay<Item>()

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        bind()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
    }

    // MARK: - Private

    private func bind() {
        let input = ListViewModel.Input(
            viewWillAppearStream: rx.sentMessage(#selector(viewWillAppear(_:))).map { _ in },
            viewDidReachBottom: tableView.rx.reachedBottom.asObservable(),
            reloadRequestTrigger: reloadRequestTrigger.asObservable(),
            paginationRequestTrigger: paginationRequestTrigger.asObservable(),
            changeDataTrigger: changeDataTrigger.asObservable()
        )
        let output = viewModel.transform(input: input)

        output.itemsStream
            .bind(to: tableView.rx.items(dataSource: dataSource))
            .disposed(by: disposeBag)

        do {
            let binder = Binder(self) { (vc, error: Error) in
                // error
            }

            output.error
                .bind(to: binder)
                .disposed(by: disposeBag)
        }

        do {
            let binder = Binder(self) { (vc, item: Item) in
                let detailVC = PageViewController(itemsStream: output.itemsStream,
                                                                     paginationRequestTrigger: vc.paginationRequestTrigger,
                                                                     selectedItem: item)
                vc.navigationController?.pushViewController(detailVC, animated: true)
            }

            tableView.rx
                .modelSelected(Item.self)
                .bind(to: binder)
                .disposed(by: disposeBag)
        }

        output.loading
            .bind(onNext: { $0 ? LoadingIndicator.show() : LoadingIndicator.dismiss() })
            .disposed(by: disposeBag)
    }
}

private class DataSource: NSObject, UITableViewDataSource {
    typealias Element = [Item]
    private var items: [Item] = []

    func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let item = items[indexPath.row]
        let cell = UITableViewCell(style: .default, reuseIdentifier: "cell")

        cell.textLabel?.text = item.name
        return cell
    }
}

extension DataSource: RxTableViewDataSourceType {
    func tableView(_ tableView: UITableView, observedEvent: RxSwift.Event<Element>) {
        Binder(self) { dataSource, elements in
            dataSource.items = elements
            tableView.reloadData()
        }
        .on(observedEvent)
    }
}

extension DataSource: SectionedViewDataSourceType {
    func model(at indexPath: IndexPath) throws -> Any {
        return items[indexPath.row]
    }
}

ViewModelの処理の概要は、後述します。

このListViewControllerでやってることの概要としては

  • (1) UIイベントに依存せず、任意で発火させたいトリガーを宣言する
  • (2) 一覧取得APIを実行の責務は、ViewModelに持たせるため、各トリガーをViewModelへ渡す
  • (3) ViewModelで流れたストリームの結果を処理
  • (4) tableViewの各イベントをストリームで処理

(1) UIイベントに依存せず、任意で発火させたいトリガーを宣言する

private let reloadRequestTrigger = PublishRelay<Void>()
private let paginationRequestTrigger = PublishRelay<Void>()
private let changeDataTrigger = PublishRelay<Item>()

ボタンクリックなど、UIイベントをトリガーとする場合は、RxCocoaで、イベントをObservable化すればいいですが、そうでなく任意に発火したい場合は、トリガー用のObservableを用意します。
各トリガーの説明は以下になります。

変数               説明                              発火場所
reloadRequestTrigger 現在のページ番号を考慮しない初回のAPIリクエストを実行するトリガー Pull to Refresh やエラー時のリトライなど
paginationRequestTrigger 現在のページ指定を元に追加データを取得するトリガー スクロールイベントなどに起因せず、任意に追加読み込みをしたい箇所
changeDataTrigger 特定のデータ (Item)だけを入れ替えるトリガー 編集画面から戻ってきた際に、該当するデータだけ反映したい場合など

(2) 一覧取得APIを実行の責務は、ViewModelに持たせるため、各トリガーをViewModelへ渡す

各トリガーごとに、異なった処理が必要ですが、それぞのロジックは、ViewController側ではなくViewModelで処理を行います。
これは、ViewModelをテスタブルにして、ViewController側は何も気にせずに処理を実行させたいという意図があります。
現在、アドバイザーとしていろいろ教えて頂いているスーパーなiOSエンジニアの方が、『ViewControllerはアホな方がいい』と言ってました。
この格言、めっちゃ気に入りましたw

ViewModelへバインドしている箇所です。

let input = ListViewModel.Input(
    viewWillAppearStream: rx.sentMessage(#selector(viewWillAppear(_:))).map { _ in },
    viewDidReachBottom: tableView.rx.reachedBottom.asObservable(),
    reloadRequestTrigger: reloadRequestTrigger.asObservable(),
    paginationRequestTrigger: paginationRequestTrigger.asObservable(),
    changeDataTrigger: changeDataTrigger.asObservable()
)

ここでのviewWillAppearStreamについては、その名前の通り、UIViewControllerのviewWillAppearに反応してフックしているトリガーです。
ここでのreachedBottomについては、UIScrollViewを拡張して、RxCocoaでスクロールのボトムを検知しているものです。

(3) ViewModelで流れたストリームの結果を処理

ViewModelで組んだストリームの結果を、Viewに反映するためのロジックです。

let output = viewModel.transform(input: input)

output.itemsStream
    .bind(to: tableView.rx.items(dataSource: dataSource))
    .disposed(by: disposeBag)

do {
    let binder = Binder(self) { (vc, error: Error) in
        // error
    }
    output.error
        .bind(to: binder)
        .disposed(by: disposeBag)
}

リクエストが成功した際のストリームをtableViewのdataSourceへバインドしているのと
リクエストが失敗した際のストリームをエラー処理する為の、Binderへバインドしています。

(4) tableViewの各イベントをストリームで処理

これについては、特に目新しいことをやっていることもないので、割愛させて頂きます。

ListViewModelについて

前述させて頂いたように、@yimajoさんの書籍を元に、sergdort氏のViewModelパターンを採用しています。
こちらに関しては、@yimajoさんの書籍を参照頂くか、以下のサイトを参照ください。
ViewModel in RxSwift world - Serg Dort - Medium

このViewModelの責務としては、ItemをAPI経由で取得し、ストリームとしてViewControllerへ返すだけです。
が!、今回の実装の肝となる部分なので、少々細かく説明します。

ItemResponse.swift
struct ItemResponse: Decodable {
    let items: Observable<[Item]>
    public let pages: Pages
}

public struct Pages: Codable {
    public let currentPage: Int
    public let nextPage: Int?
}

APIから返ってくるレスポンスが上記のようだと仮定して、ListViewModelの処理は以下のようになります。

ListViewModel.swift
import Foundation
import RxSwift
import RxCocoa
import APIKit

struct ListViewModel {
    private let disposeBag = DisposeBag()

    struct Input {
        let viewWillAppearStream: Observable<Void>
        let viewDidReachBottom: Observable<Void>
        let reloadRequestTrigger: Observable<Void>
        let paginationRequestTrigger: Observable<Void>
        let changeItemTrigger: Observable<Item>
    }

    struct Output {
        let loading: Observable<Bool>
        let itemsStream: Observable<[Item]>
        let error: Observable<Error>
    }

    func transform(input: Input) -> Output {
        let session = Session.shared

        let nextPageNumber = PublishRelay<Int?>()
        let startRequestTrigger = input.viewWillAppearStream.take(1).map { 1 } // 1 is a first page number
        let itemsRequestTrigger = input.reloadRequestTrigger.map { 1 } // 1 is a first page number
        let paginationRequestTrigger = PublishRelay.merge(
                                            input.viewDidReachBottom.asObservable()
                                                .throttle(.seconds(1), latest: false, scheduler: MainScheduler.instance),
                                            input.paginationRequestTrigger.asObservable()
                                                .throttle(.seconds(1), latest: false, scheduler: MainScheduler.instance)
                                        )
                                        .withLatestFrom(nextPageNumber)
                                        .compactMap { $0 }

        let load = PublishRelay.merge(startRequestTrigger, paginationRequestTrigger, itemsRequestTrigger).share()

        let sequence = load
            .flatMapLatest { nextPage -> Observable<Event<ItemResponse>> in
                return session.rx
                        .send(ItemsRequest(page: nextPage))
                        .asObservable()
                        .materialize()
            }
            .share()

        let elements = sequence.compactMap { $0.event.element }.share()

        elements
            .map { $0.pages.nextPage }
            .bind(to: nextPageNumber)
            .disposed(by: disposeBag)

        let elementsWithPagination = elements
            .scan([]) { $1.pages.currentPage == 1 ? $1.items : $0 + $1.items }
            .startWith([])
            .share(replay: 1)

        let elementsWithChanged = input.changeItemTrigger
            .withLatestFrom(elementsWithPagination) { ($0, $1) }
            .map { arg -> [Item] in
                let item = arg.0
                var items = arg.1
                if let index = items.firstIndex(where: {$0.id == item.id}) {
                    items[index] = item
                }
                return items
            }

        let itemsStream = Observable
            .merge(elementsWithPagination, input.reloadRequestTrigger.map { [] }, elementsWithChanged)
            .share(replay: 1)

        let loading = PublishRelay.merge(load.map { _ in true }, sequence.map { _ in false })

        return Output(
            loading: loading,
            itemsStream: itemsStream,
            error: sequence.compactMap { $0.event.error }
        )
    }
}

このListViewModelでやってることの概要としては

  • (1) 各トリガーに流すデータをページ番号で統一
  • (2) 各トリガーをマージして、トリガーが発火したらストリームが実行されるようにする
  • (3) APIを実行して、ページ番号を購読
  • (4) APIの実行結果を、ストリームの中で保持している前回の結果にマージ
  • (5) 部分的に変更があった場合、ストリームの中で保持している結果を元に差し替える
  • (6) APIリクエストが必要なトリガーと、APIリクエストが不要となるトリガーをマージして、ストリームを構成する

(1) 各トリガーに流すデータをページ番号で統一

最終的に、ストリームに流れるデータは、ページ番号を起因にして、変化するので、発火する際に流す値は、ページ番号を長す
viewWillAppearやreloadRequest発火時のリクエストについては、ViewControllerからページ番号を渡す必要がないので、ViewModelの中でストリームを直接、書き換えます。

let nextPageNumber = PublishRelay<Int?>()
let startRequestTrigger = input.viewWillAppearStream.take(1).map { 1 } // 1 is a first page number
let itemsRequestTrigger = input.reloadRequestTrigger.map { 1 } // 1 is a first page number

追加読み込みによるAPIリクエストは、一覧をスクロールして最下部に到達した場合、任意に読み込み発生させたい(PageViewController内)の両方のパターンがあるので、その二つのトリガーをマージします。

let paginationRequestTrigger = PublishRelay.merge(
                                    input.viewDidReachBottom.asObservable()
                                        .throttle(.seconds(1), latest: false, scheduler: MainScheduler.instance),
                                    input.paginationRequestTrigger.asObservable()
                                        .throttle(.seconds(1), latest: false, scheduler: MainScheduler.instance)
                                )
                                .withLatestFrom(nextPageNumber)
                                .compactMap { $0 }

連打防止の為、throttleで無駄なストリームが流れないように制御しています。

(2) 各トリガーをマージして、トリガーが発火したらストリームが実行されるようにする

ここでは、3パターンあるトリガーのうち、どれかが発火されたらストリームが流れるようにマージします。

let load = PublishRelay.merge(startRequestTrigger, paginationRequestTrigger, itemsRequestTrigger).share()

(3) APIを実行して、ページ番号を購読

一覧取得のAPIリクエストを実行しています。

let sequence = load
    .flatMapLatest { nextPage -> Observable<Event<ItemResponse>> in
        return session.rx
                .send(ItemsRequest(page: nextPage))
                .asObservable()
                .materialize()
    }
    .share()

ここでは、ItemsRequestの中身については、触れません。
単に、APIKitをラッピングしているのみです。

RxSwiftのmaterializeで、APIの成功のストリームと失敗のストリームに分けて、成功のストリームが流れたときに、ページ番号を購読しています。

let elements = sequence.compactMap { $0.event.element }.share()

elements
    .map { $0.pages.nextPage }
    .bind(to: nextPageNumber)
    .disposed(by: disposeBag)

(4) APIの実行結果を、ストリームの中で保持している前回の結果にマージ

(成功の)ストリームの中で、保持しているデータに前回の結果が含まれていれば、それに新しい結果を追加して、それをストリームに流すようにします。

let elementsWithPagination = elements
            .scan([]) { $1.pages.currentPage == 1 ? $1.items : $0 + $1.items }
            .startWith([])
            .share(replay: 1)

これは、APIの結果が、2ページ目、3ページ目で、リクエストごとに、前ページの結果を含んでいない場合のAPIの仕様の場合で、ストリームの外から前回の結果を差し込まずに、時系列で保持している前回の値を効率よく使う為のロジックです。

最初、ストリームの外から差し込んじゃえというようなロジックを書いてたんですが、スーパーエンジニアにダメ出しされました。
今考えると当たり前ですが。汗

(5) 部分的に変更があった場合、ストリームの中で保持している結果を元に差し替える

これも (6) と同じく、既にストリームの中に流れている値 (elementsWithPagination) を元に、部分的にデータを差し替えるロジックです。
編集を行った際などに、APIリクエストを伴わなずに、差し替えたいなと思った場合、上流のストリームから流してしまうと、かなり複雑なストリームになってしまうので、API実行後のストリームに対し、差し替えを行っています。

let elementsWithChanged = input.changeItemTrigger
    .withLatestFrom(elementsWithPagination) { ($0, $1) }
    .map { arg -> [Item] in
        let item = arg.0
        var items = arg.1
        if let index = items.firstIndex(where: {$0.id == item.id}) {
            items[index] = item
        }
        return items
    }

(6) APIリクエストが必要なトリガーと不要となるトリガーをマージして、ストリームを構成する

(5) で説明したようにAPI実行後のストリームの値に変化をつけて、その結果をViewControllerへ流したいので、本流のストリームとマージして、両方をトリガーとして、流れるようにしています。

let itemsStream = Observable
            .merge(elementsWithPagination, input.reloadRequestTrigger.map { [] }, elementsWithChanged)
            .share(replay: 1)

ViewController側でこういった処理を行おうとすると、かなり助長なコードになるのが予想できるので、途中で話した『ViewControllerはアホな方がいい』というのは、非常に納得できるのではないでしょうか。

(補足) ローディングの表示について

ここまで触れてこなかったのですが、ViewController側で、くるくるのローディング表示を行うに当たって、ストリームが流れているかどうかで判断させることで、ViewControllerは、そのBool値を追いかけるだけでよくなります。

let loading = PublishRelay.merge(load.map { _ in true }, sequence.map { _ in false })

上流のトリガーが発火したタイミングでtrue、ストリームが完了したタイミングでfalseを流すことで、ローディング表示のオンオフが容易になります。

DetailViewControllerについて

今回の説明では、解説は不要なので割愛させて頂きます。

最終的なストリームのイメージ

最終的に組んだストリームのイメージは、こんな感じになります。
厳密には、ちょっと違うかもしれませんが、Itemsの取得に関するストリームは、全てListViewModelの中のストリームを通って、取得している感じです。

UX体験-Page-2.png

まとめ

実は、RxSwiftやリアクティブプログラミングは、Classiに入社する前は、興味はあれど、触ったことがなく、入社してからキャッチアップさせてもらったのですが、さすが教育分野の会社だけあって、社内メンバーの学習意欲の高さや、学習欲に対する理解度は高いです。

オブジェクト指向プログラミングを長くやっていたので、リアクティブプログラミングに切り替えるのは、かなり苦労しましたが、今ではかなり理解度もあがり、オブジェクト指向プログラミングに戻って、助長なプログラムを書いてしまうのが、怖いくらいになりました。

ただ、まだまだリアクティブプログラミングの奥は深く、ただでさえ、正解がないプログラミングの世界で、さらに正解がぼやけてしまう感じで、例えて言うならば、RPGゲームで、どの武器を使ってボスと戦かおうかの選択肢の中に、魔法や属性が加わって、武器の選択だけでは、戦えないといった感じです。

むしろ、リアクティブプログラミングという方法を知ってしまった今は、バル○やパルプン○といった呪文を覚えてしまった感覚にもなり、全てを破壊したり、混乱に陥れる可能性もあるヤバイスキルを身につけてしまった感もあります。

ただ、Appleも、Combineフレームワークを発表したように、時代は、リアクティブプログラミングなど、時系列を考慮したプログラミングを組めるようにならないと、取り残されていくなと感じました。

今回の実装も、複雑な処理は、ほとんど、ViewModelの中にコンパクトに収められていて、RxSwiftを導入していなければ、色々煩雑なロジックを書く必要があったなと、RxSwiftを採用したメリットをかなり感じています。

ここまで来るには、いろんな壁にぶち当たって、その都度、社内のiOSエンジニアと、アドバイスを頂いているスーパーエンジニアのご協力もあって、実装できましたので、リスペクトを兼ねて、解説する記事を書くことにしました。
おかげで、自分自身もかなり、RxSwiftやリアクティブプログラミングの理解が深まり、こういった記事が書けるようになったので、本当に感謝しています。

Classiには、こんなアプリエンジニアがいるんだというようなイメージアップに繋がればいいなと思っていますので、是非、今後ともよろしくお願いいたします。

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

Swift:macのネットワーク上り下りスピードを取得する

  • CapabilitiesIncoming Connections (Server)またはOutgoing Connections (Client)にチェックを入れる
class Network {

    private var lastUpBytes: Int?
    private var lastDownBytes: Int?

    // interval秒ごとのスピード
    func speed(interval: Int) {
        let process = Process()
        process.launchPath = "/usr/bin/env"
        process.arguments = ["netstat", "-bdI", "en0"]

        let pipe = Pipe()
        process.standardOutput = pipe
        process.launch()

        let data = pipe.fileHandleForReading.readDataToEndOfFile()
        guard var commandOutput = String(data: data, encoding: .utf8) else {
            return
        }
        commandOutput = commandOutput.lowercased()
        let lines = commandOutput.components(separatedBy: .newlines)
        let stats = lines[1].components(separatedBy: .whitespaces).compactMap({ (str) -> String? in
            return str.isEmpty ? nil : str
        })
        let upBytes = Int(stats[9]) ?? 0
        let downBytes = Int(stats[6]) ?? 0
        var upSpeed: Int = 0
        var downSpeed: Int = 0
        if lastDownBytes != nil && lastUpBytes != nil {
            upSpeed = (upBytes - lastUpBytes!) / interval
            downSpeed = (downBytes - lastDownBytes!) / interval
        }
        lastUpBytes = upBytes
        lastDownBytes = downBytes

        Swift.print("Upload: \(convert(byte: Double(upSpeed)))")
        Swift.print("Download: \(convert(byte: Double(downSpeed)))")
    }

    private func convert(byte: Double) -> String {
        let KB: Double = 1024
        let MB: Double = pow(KB, 2)
        let GB: Double = pow(KB, 3)
        var str = ""
        if GB <= byte {
            str = String((10 * byte / GB).rounded() / 10) + " GB/s"
        } else if MB <= byte {
            str = String((10 * byte / MB).rounded() / 10) + " MB/s"
        } else {
            str = String((10 * byte / KB).rounded() / 10) + " KB/s"
        }
        return str
    }

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

iOS13.3 @Publishedでの値更新からsinkが呼ばれなくなった?(ミス解決)

iOS13.3では@Publishedでの値更新でsinkが呼ばれない?

起きたこと

iOS13.3に更新したところ、SwiftUIのEnvironmentObject上の@Publishedの更新通知が受け取れなくなりちょっとハマりました。結局の所、もともと更新通知を受け取る処理を書き間違えていたことが原因でした。

修正前

    let _ = self.model.$text.receive(on: DispatchQueue.main)
    .sink { (value) in
        print("receive")
        self.text = value
    }

修正後

var task:AnyCancellable? = nil
...
    self.task = self.model.$text.receive(on: DispatchQueue.main)
    .sink { (value) in
        print("receive")
        self.text = value
    }

きちんと戻り値のAnyCancellableを保持していないことが問題でした。考えてみれば納得なのですが、iOS13.2までは動いていたので見落としていました。戒めの意味を込めて記録しておきます。

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

パラメタライズドテストのメリット解説(Swift)

はじめに

本記事は Swift Advent Calendar 2019 の14日目の記事です。
パラメタライズドテストのメリットについて解説します。

注意

本記事は 「YUMEMI.swift #5 ~Free Talk~」のLTで使用した資料 を元にしています。
LT時は MarkdownをVimで表示した ので、よかったら併せて見てください。

用語

本記事で使う用語を説明します。

用語 意味
テストメソッド 自動テストのメソッド
テストコード 自動テストのコード
プロダクトコード 通常のソースコード。テストコードの対義語として使う

あるメソッドのテストケースを考える

ここから本題です。

エンジニアが働けるかどうかを判定するメソッドを用意しました。
やる気が100以上でRed Bullを持っている場合のみ働けます。

func canWork(motivation: Int, hasRedBull: Bool) -> Bool {
    if motivation < 100 {
        return false
    }

    if !hasRedBull {
        return false
    }

    return true
}

こちらのメソッドのテストケースは全部で何パターン必要でしょうか?

あるメソッドのテストケース数

私が考える正解は、以下の4パターンです。

motivation hasRedBull return value
99 true false
99 false false
100 true true
100 false false

motivation を境界値分析すると、 99100 が境界値とわかります。
さらに「100未満」と「100以上」で同値分割できるので、 99100 の2通りのみテストすれば十分です。

hasRedBull はBool型なので truefalse の2通りしかありません。

2通り × 2通り = 4パターン です。

手動でテストする場合

上記のメソッドを 手動で テストする場合、エクセルなどを使って一覧表に○×を付けるのが一般的だと思います。

先ほどの表に「No」「判定」「日付」「担当」列を追加しました。

No motivation hasRedBull return value 判定 日付 担当
1 99 true false OK 2019/12/14 ウホーイ
2 99 false false OK 2019/12/14 ウホーイ
3 100 true true NG 2019/12/14 ウホーイ
4 100 false false OK 2019/12/14 ウホーイ

これで十分であり、表になっているのでパターンが網羅できているかもわかりやすいです。

自動でテストする場合

上記のメソッドを 自動で テストする場合はどうでしょうか?
最も簡単な方法として、1ケース1テストメソッドとして実装することが考えられます。

1ケース1テストメソッドとして実装
func test_canWork_motivation_99_hasRedBull_true() {
    let logic = LogicSample()
    XCTAssertFalse(logic.canWork(motivation: 99, hasRedBull: true))
}

func test_canWork_motivation_99_hasRedBull_false() {
    let logic = LogicSample()
    XCTAssertFalse(logic.canWork(motivation: 99, hasRedBull: false))
}

func test_canWork_motivation_100_hasRedBull_true() {
    let logic = LogicSample()
    XCTAssertTrue(logic.canWork(motivation: 100, hasRedBull: true))
}

func test_canWork_motivation_100_hasRedBull_false() {
    let logic = LogicSample()
    XCTAssertFalse(logic.canWork(motivation: 100, hasRedBull: false))
}

テストケースごとにテストメソッドを実装することがあるかもしれませんが、実は以下のデメリットがあります。

網羅できているかわかりづらい
エクセルのようにテストケースが一覧になっていないので、網羅できているかわかりづらいです。

同じような処理で読みづらい
canWork() メソッドに渡す引数以外は同じ処理なので、どこが違うか注力して読み比べる必要があります。
同じ処理を繰り返すのはDRY原則にも反しています。

メソッド名に悩む
テストメソッドの命名はメソッド数が増えるほど難しくなり、 testCanWork2() のように末尾に連番を振ることがあるかもしれません。
今回は引数と値をそのままメソッド名に付けましたが、適切ではない気がします。

テストコードがわかりづらいとどうなるか

テストコードの可読性が低い場合、 テストコードが保守されなくなる 可能性が高まります。
テストコードはプロダクトコードに比べて可読性が無視されがちですが、それだとテストが失敗しても直せなくなり、テストが成功しなくてもPRを通すようになります。

自動テストはうまく使うとプロダクトコードを安全に破壊できるので、もったいないです。

どうやったらテストコードがわかりやすくなる?

先ほどのデメリットにも上げた通り、 自動テストでもエクセルのように一覧になっていたら わかりやすくなると思いませんか?

自動テストでテストケースを一覧にする
// | motivation | hasRedBull | return value |
// | ---------: | :--------- | :----------- |
// |         99 | true       | false        |
// |         99 | false      | false        |
// |        100 | true       | true         |
// |        100 | false      | false        |
//
// ↓
//
let testCases: [(motivation: Int, hasRedBull: Bool, expect: Bool)] = [
    ( 99, true,  false),
    ( 99, false, false),
    (100, true,  true ),
    (100, false, false)
]

どうでしょうか?
テストケースをタプルで表して配列に入れることで、エクセルのように一覧で見れるようになりました。

タプルはtypealiasを使って別名を付けることで、型のように扱えます。

タプルに別名を付ける
typealias TestCase = (motivation: Int, hasRedBull: Bool, expect: Bool)
let testCases: [TestCase] = [
    ( 99, true,  false),
    ( 99, false, false),
    (100, true,  true ),
    (100, false, false)
]

ちょっとした工夫ですが、より可読性が上がりました。
構造体を定義するより簡単に使えるので、型を使い捨てる場合には便利です。

あとは作成した配列をループさせ、1つずつアサーションすればOKです。
テストメソッドの全体は以下の通りです。

工夫したテストメソッドの全体
func test_canWork() {
    typealias TestCase = (line: UInt, motivation: Int, hasRedBull: Bool, expect: Bool)
    let testCases: [TestCase] = [
        (#line,  99, true,  false),
        (#line,  99, false, false),
        (#line, 100, true,  true ),
        (#line, 100, false, false)
    ]

    for (line, motivation, hasRedBull, expect) in testCases {
        let logic = LogicSample()
        let result = logic.canWork(motivation: motivation, hasRedBull: hasRedBull)
        XCTAssertEqual(result, expect, line: line)
    }
}

#line はソースコードの行番号を表し、これを渡すことでテストの失敗時に対象の要素でエラーになってくれます。
スクリーンショット 2019-12-15 0.55.49.png

ただ、エラーメッセージに変数名が出力されないため、若干わかりづらいです。
いちいち行番号を渡すのも冗長なので、このあたりをいい感じに吸収してくれるライブラリを作りたいです。

パラメタライズドテスト

引数のみを切り替えて実行するテストを パラメタライズドテスト といいます。

言語によって呼び方が異なることがあります。

言語 名前
Go テーブル駆動テスト
Java (JUnit) パラメータ化テスト

他言語では一般的なのに、なぜかSwiftではあまり知られていません。

以下のようにメリットも多く、簡単に導入できるのでオススメです。

  • テストパターンが網羅できているかひと目でわかる
  • 1メソッドで済むので処理が重複せず、命名にも困らない
  • テストケースを追加・変更・削除しやすい
    1行変更するのみ

おまけ

JavaのテストフレームワークであるSpockには「Data Tables」という機能があり、より直感的にテストケースを記述できます。

Spockによるテスト
class MathSpec extends Specification {
  def "maximum of two numbers"(int a, int b, int c) {
    expect:
    Math.max(a, b) == c

    where:
    a | b | c
    1 | 3 | 3
    7 | 4 | 7
    0 | 0 | 0
  }
}

Swiftでは、独自演算子とFunction Builderを使って実現できそうな気もします。
強い方教えてください…。

おわりに

パラメタライズドテストのメリットが少しでも伝わっていると嬉しいです?

以上、 Swift Advent Calendar 2019 の14日目の記事でした。
15日目は @freddi_ さんの記事です。

参考リンク

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

SwiftPMとSlackKitを使ってSlackBotを作った話

この記事は Willgate Advent Calendar 2019 の15日目の記事です。

昨日は @ryota_hnk さんの OKE(Oracle Container Engine for Kubernetes) を Rancher で監視する でした。
本日は、ウィルゲートでPHPやJavaScript、CSSで開発している@N06ARです!
よろしくお願いします!

はじめに

私は普段の業務でSwiftを書くことがありませんが、最近で昔作ったiOSアプリをSwift1からSwift5にジャンプアップデートする機会がありました。
久しぶりのSwiftが楽しかったので、SlackKitを使いSlackBotを作ってみました。
そこでこの記事では、SlackKitを使ったSlackBotの導入をご紹介します!

ここで書かれていること

この記事では次の3点について書いています。

  • SlackKitの導入方法
  • SlackKitを使ったSlackでのメッセージの送受信
  • SlackKitで作ったSlackBotを使ってみての感想x

この3点で、私が失敗や上手く行かなかったところは(:bomb:)を書いています。
もし、この記事を読んで作ろう!と思った方は、参考にしてみてください!

開発環境

開発環境は、次のバージョンで開発しました。

バージョン
OS macOS Catalina
Swift 5.1
SlackKit 4.5.0
Xcode 11

0.事前準備

コードを書き始める前に、事前に必要なものを準備します。

0-1.SlackでAPI Tokenの発行

はじめにSlackからAPI Tokenを取得してきます。
このURLからAPI Tokenを発行します。

Botの作成

Botの名前を入力し、botを追加するボタンを押すと次の画像が一部として表示される画面が表示されます。
画像のところにAPI Tokenがあるのでメモしておいてください。
(画像のTokenはダミーです)

API Tokenの表示箇所

0-2.(オプション)LibreSSLのインストール

SlackKitの依存パッケージの中にSwiftNIOがあり、LibreSSLが必要になります。
インストールしていなければ1-5.ビルドでビルドに失敗するのでインストールします。(:bomb:

$ brew install libressl

1.SwiftPMの設定

SwiftPMを使いSwiftパッケージディレクトリ(プロジェクトフォルダ)を作成し、SlackKitを使えるようにします。

1-1.SlackKitの実行パッケージディレクトリの作成

次のコマンドを実行して、SlackBotを作るディレクトリを作成し、実行パッケージディレクトリを作成します。
swift package init --type executableを実行した時に特に入力を求められませんが、問題ありません。

$ mkdir 【プロジェクト名】
$ cd 【プロジェクト名】
$ swift package init --type executable

1-2.(オプション)実行確認

次のコマンドを実行した後 Hello, world!と表示されたら準備完了です!

$ swift build
$ ./.build/debug/【プロジェクト名】

1-3.SwiftPMで使用するパッケージにSlackKitを追加

次にPackage.swiftを編集します。
Package.swiftをエディタで開きdependenciesの箇所に今回使用するパッケージのSlackKitを追加します。

Package.swift
import PackageDescription

let package = Package(
    name: "【プロジェクト名】",
    dependencies: [
        .package(url: "https://github.com/pvzig/SlackKit.git", .upToNextMinor(from: "4.5.0"))
    ]
)

1-4. (オプション)テストもしたい場合

テストもしたい場合は、targetsにもSlackKitのパッケージを追加する必要があります。
SlackKitの追加を忘れると1-5.ビルドでビルドに失敗します。(:bomb:

Package.swift
import PackageDescription

let package = Package(
    name: "【プロジェクト名】",
    dependencies: [
        .package(url: "https://github.com/pvzig/SlackKit.git", .upToNextMinor(from: "4.5.0"))
    ],
    targets: [
        .target(
            name: "【プロジェクト名】",
            dependencies: ["SlackKit"]),
        .testTarget(
            name: "【プロジェクト名】Tests",
            dependencies: ["【プロジェクト名】","SlackKit"]),
    ]
)

1-5.ビルド

Package.swiftを編集した後に、 swift buildをするとSlackKitを取得し使えるようになります。

$ swift build

1-6.(オプション)XcodeやAppCodeを使う場合

XcodeやAppCodeを使いたい感じると思います。その時は次のコマンドを実行して.xcodeprojを生成してください。

$ swift package generate-xcodeproj

ただし、Package.swiftにパッケージを追加変更する度に、.xcodeprojを再生成する必要があります。(:bomb:
AppCodeで発生しましたが、プロジェクト内のファイル名を変更したときビルドエラーになりました。
その時も再生成してあげると良さそうです。(:bomb::bomb:

2.SlackBotのコードを書く

ここから実際にSlackKitを使ってSlackbotのコードを書いていきます。

2-1.main.swiftの編集

まずはじめに、今回SwiftPMで作成した のエントリーポイントのmain.swiftを編集します。
今回は、SlackBotの機能をmain.swiftに書かずSlackBotクラスを作成し分離します。
SlackBotのインスタンス生成時に、0-1.SlackでAPI Tokenの発行 で準備したAPI Tokenを引数に入れます。

Sources/【プロジェクト名】/main.swift
import Foundation
SlackBot(apiToken: "xoxb-XXXXXXXXXXXXXXXXXXXX")
RunLoop.main.run()

2-3.SlackBotのクラスを作成

次に、SlackBotクラス用のファイルを作成し、コードを作成していきます。
コンストラクタ(init)で、API Tokenを受け取れるようにして、
SlackKitでSlack Web APISlack Real Time Messaging API使えるようにするためにAPI Tokenを渡します。
API Tokenを渡すには、addWebAPIAccessWithTokenaddRTMBotWithAPITokenを使用します。

Sources/【プロジェクト名】/SlackBot.swift
import Foundation
import SlackKit

class SlackBot {
    /// SlackBotのKit
    let slackkit = SlackKit()

    init(apiToken: String) {
        slackkit.addWebAPIAccessWithToken(apiToken)
        slackkit.addRTMBotWithAPIToken(apiToken)
    }
}

2-4.メッセージを受け取る

次にSlackからメッセージを受け取ります。
メッセージは、SlackKitのnotificationForEventを使い受け取ります。
受け取ったメッセージの内容は、クロージャのevent.messageに入っており、
event.messageには発言したユーザIDやチャンネルID、スレッドID、メッセージの文章等が入っています。

Sources/【プロジェクト名】/SlackBot.swift
import Foundation
import SlackKit

class Slackbot {
    /// SlackBotのKit
    let slackkit = SlackKit()

    init(apiToken: String) {
        slackkit.addWebAPIAccessWithToken(apiToken)
        slackkit.addRTMBotWithAPIToken(apiToken)
        slackkit.notificationForEvent(.message) { [weak self] (event, client) in
            // ここに聞いたメッセージを受信したときのメッセージを書く!
        }
    }
}

【余談】SlackKitのサンプルコードのように書くときの注意

サンプルコードのこの箇所のように、実装した場合でAPI Tokenを渡すと
listenメソッド実行時に、clientクラスが生成されないためかnilのためguard構文でreturnされるので何もせずに終わります。(:bomb:

Sources/【プロジェクト名】/SlackBot.swift
import Foundation
import SlackKit

class SlackBot {
    /// SlackBotのKit
    let slackkit = SlackKit()

    init(apiToken: String) {
        slackkit.addWebAPIAccessWithToken(apiToken)
        slackkit.addRTMBotWithAPIToken(apiToken)
        slackkit.notificationForEvent(.message) { [weak self] (event, client) in
            self?.listen(client?.client, message: event.message)
        }
    }

    private func listen(_ client: Client?, message: Message?) {
        guard let client = client,
              let message = message,
              let text = message.text,
              let channel = message.channel
                else {
            return
        }
    } 
}

2-5.メッセージを送信する

メッセージを受け取れるようになったので、今度はメッセージを送れるようにします。
今回は、全てのメッセージに大してこんにちはと返事をするメッセージを送信する処理を書きます。
メッセージの送信には、SlackKitWebAPIクラスのsendMessageメソッドを使用します。
sendMessageメソッドの引数には、送信するチャンネル表示するユーザネームアイコン画像のURLメッセージを入れてください。

Sources/【プロジェクト名】/SlackBot.swift
import Foundation
import SlackKit

class NanaChangbot {
    /// SlackBotのKit
    let slackkit = SlackKit()

    init(apiToken: String) {
        slackkit.addWebAPIAccessWithToken(apiToken)
        slackkit.addRTMBotWithAPIToken(apiToken)
        slackkit.notificationForEvent(.message) { [weak self] (event, client) in
            // ここに聞いたメッセージを受信したときのメッセージを書く!
            slackkit.webAPI?.sendMessage(
                channel: message.channel,
                text: "こんにちは",
                username: "【botの名前】",
                iconURL:  "【アイコン画像のURL】",
                success: nil,
                failure: { (error) in
                    print("メッセージ送信失敗:\(error)")
                }
            )
        }
    }
}

これでbotが入ったチャンネルか、DMでメッセージを送った時にこんにちはと返事するようになりました!
メッセージ以外にもAttachmentクラスを使いAttachmentをつけることもできます。

【余談】アイコン画像とBotの名前がない場合

実のところ表示するユーザネームアイコン画像のURLは、必須ではないので引数に入れず送信することができます。
仮に引数なしで送信をした場合は、次の画像の結果になります。
表示するユーザネームとアイコン画像のURLがない場合の画像
アイコン画像がbotの画像になり、名前がbotになります。(:bomb:
そのため、名前と画像のURLを渡たしてあげる必要があります。

2-6.起動!!

最後ビルドして起動します。
起動には、デバックモードの起動とリリースモードの起動があります。

2-6-1.デバックモードで起動!

ビルドは今まで通り次のコマンドを入力してください。

$ swift build

最後に次のコマンドで起動します!
コマンド実行するとシェルの入力はできなくなるので、nohupを使いバックグラウンド実行すると良いです。

.build/debug/【プロジェクト名】

2-6-2.リリースモードで起動!

ビルドコマンドは今までとは少し異なります。

$ swift build -c release

最後に次のコマンドで起動します!

.build/release/【プロジェクト名】

3.macOS以外で起動したい!

SlackBotを動かすのにLinuxやサービスを使って動かしたいことがあると思います。
そこで、HerokuDockerで動作させる方法をご紹介します。

3-1.Herokuにデプロイしたい場合

Herokuにデプロイする場合は、HerokuCLIを使用します。
そのため、次のコマンドでHerokuCLIをインストールしてください。

brew tap heroku/brew && brew install heroku

3-1-1.Heroku用設定ファイルの追加

はじめに、Heroku用の設定ファイルを追加します。
次のコマンドを実行して、.swift-versionProcfileを作成してください。

$ echo 5.1 > .swift-version
$ echo slackbot: .swift-bin/【プロジェクト名】 > Procfile

Herokuでデプロイするために、heroku-buildpack-swiftを使用します。
heroku-buildpack-swiftでは、ビルドしたときのディレクトリ名は.buildではなく.swift-binなので気をつけてください。(:bomb:

3-1-2.Herokuの設定

次にHerokuCLIでアカウントにログインし、Herokuにアプリケーションを作成します。
アプリケーション作成時に、heroku-buildpack-swiftをbuildpackとして追加します。

$ heroku login
$ heroku create --buildpack kyle/swift 【プロジェクト名】

3-1-3.デプロイ

Herokuに、コードをpushすればheroku側でデプロイが開始されます。

$ git push heroku master

起動するには、Herokuの画面からslackbotDynosをオンにします。

3-2.Dockerのコンテナにデプロイしたい場合

次は、DockerでSlackBotを起動する方法をご紹介します。

3-2-1.Dockerfileを書く

まず、プロジェクトのディレクトリに次のDockerfileを追加してください。
(他のディレクトリにDockerfileを置く場合はCOPYの第1引数を修正してください。)
libssl-devをインストールしていますが、これはSwiftNIOで必要となるためです。
libssl-devをインストールしないとbuildできません。(:bomb::bomb::bomb:

FROM swift:5.1
RUN chmod -R o+r /usr/lib/swift/CoreFoundation && \
    apt-get update && \
    apt-get install -y libssl-dev
COPY . /【プロジェクト名】
WORKDIR /【プロジェクト名】
RUN swift build -c release
CMD [ "/【プロジェクト名】/.build/release/【プロジェクト名】"]

3-2-2.コンテナのビルド

次のコマンドを実行し、Dockerfileを元にビルドします。

docker build . -t slackbot

3-2-3.起動!

最後に、次のコマンドを実行してコンテナを立ち上げてください。

docker run --detach -t slackbot

これで、DockerでSlackBotの起動できます。

4.SwiftとSwiftPM、SlackKitのよかったところ、困ったところ

今回SwiftでSlackBot作りました、作っていて次のよかったところと困ったところがありました。

良かったところ

  • macOSなら開発環境がすぐにできる
  • sendMessegeメソッド

macOSならSwiftの開発環境が簡単にできる

さすが開発元がAppleなだけあり、さえXcodeをインストールすればSwiftの開発環境はおおよそ揃えられました。
SlackKitでは追加でライブラリを一つインストールする必要がありましたが
また、Xcode11からはSwiftPMのパッケージを管理できるようになりました。

sendMessegeメソッド

余談でも書きましたが、SlackKitのWebAPIを使うとSlackBotの名前とアイコン画像を変更することができます。
細かい仕様を調ベていないので詳細は分かりませんが、昔Node.jsBotKitでSlackBotを作った時はなかった仕様でした。
名前と画像が切り替えられるサンプル
これを逆手にとると、画像のようにメッセージごとに名前とアイコン画像を切り替えることができます。
これが不具合なのか、いつ修正されるか分かりませんが、アイコンを切り替えて感情豊かなBotを作れそうです。

困ったところ

  • Swiftのパッケージの大半がiOSのパッケージ
  • Herokuやbitbucket-pipelineなどがSwiftに公式で対応していない
  • 記事などの知見が少なく古い

Swiftのパッケージの大半がiOSのパッケージ

Googleカレンダーを使った機能を実装したいときに、使えそうなパッケージがないか調べるとiOSでしか使えないパッケージしかない等のことが多かったです。
使ったことがないので正確なことは言えませんが、Vaporのパッケージは色々揃っているので使うと楽になりそうです。

Herokuやbitbucket-pipelineなどがSwiftに公式で対応していない

AttrasianやHerokuなどのWebサイトを見てみるとわかりますが、自動デプロイに公式で対応してません。
そのため、Dockerで対処するか、すでにあるライブラリやパッケージ等を頼りに対応する必要がありました。

記事などの知見が少なく古い

良くありがちなことですが、今では使用できないものが書かれている記事が多かったです。
主にSlackKitを導入するにあたり、資料が古くSwiftやSwiftPMのバージョンアップにより上手く動作しないことがありました。
特にlibressl関連とHerokuDockerは、問題が解決するより、SlackBotの作成時間の方が短かったです。

今回作ったSlackBot

ななちゃんBot

今回作ったSlackBot私の家にいる なな(猫)を模したBotをつくりました。
ななちゃんBotは、私の分報チャンネルに生息しています。

Botができること

ななちゃんbotには、次の機能を実装しました。

  • メッセージにあるキーワード(ななちゃん)があるとリアクションする
  • 勤怠の打刻をリマインドとリンクの表示
  • GoogleMapでランチまたは、居酒屋を検索したURLの表示
  • ユーザを祝ったり応援する機能

残念ながら二つ目の勤怠の打刻の通知以外はあまり使われていませんが、要望や個人的に欲しかった機能を実装しました。

SlackBotを分報に入れて気がついたこと

よかったこと

Botにキャラクターを持たせること

今回たまたま思いつきでしたが、家のなな(猫)をBotにしたことは正解でした。
ななちゃんBotの最初期は、個人のSlackチームで作っていたことや、SlackKitが手探りだったこと、環境が出来上がっていなかったこともありバグが多かったです。
それに、確認できる環境がSlack上のため、本番で動作確認をしている状態でした。

ななちゃんの想定外の動き

この時はバグを発見したら極力すぐに直すようにしていました。
ですが、このバグに関してSlackでは次のような反応があり、バグがあっても良いのではという意見がありました

この自由沙がむしろ猫っぽい

この件について社内の同僚から、ご紹介いただいたこの記事に次のことが書かれていました。

バグは極力直さない
バカな子ほど可愛いという諺がある
怒られにくくするには愛着がわきやすい Bot を作るのが大切ということです

Botに何かしらのキャラクターを入れると愛着がわく

読んでみて確かにと思いました。
記事の通り愛着が沸きやすいマスコットとして作った方が周囲から可愛がられ、今後社内で広く使うには良い方法だと感じました。
また、Botで遊んでいるのを見ていると開発のモチベーションも上がるため、偶然でしたが猫のBotとして作ったのは正解だったと思いました。

失敗したこと

デプロイ方法を見つけるまでローカルで起動していた

今回HerokuとDockerでの起動方法をご紹介しましたが、先月ではまだできないでいて、
最初は私のローカル(MacBook Pro)で動作させていていました。
ただ、ローカルで動作していると、Sleepするとき会議の移動でネットワークが一時的に切断されたときに、オフラインになり使えなくなる状態ができ不便に思うことが増えました。
また、その度に再起動していたのですが、何度もやっていると流石に面倒でした。
そのためMac以外で常時起動できる方法を探しHerokuとDockerに行き着きました。

最後に

今回SlackBotをSwiftで作ることで、Swiftのverison1から久しぶりにがっつりコードをかきました。
Version1のころと比較して、結構文法が変わっていましたが変わらないところは変わっていなかったのですんなり使えるようになりました。
また今回初めて技術記事を書きましたが、こんなに文章を書くのが難しいとは思いませんでした。
普段から情報を発信している方々の凄さに驚きました。
最後になりますが、もしこの記事の内容で誤り等ありましたら、お教えいただけますと幸いです。

参考・参照文献

明日は@8845musignさんです!
よろしくお願いします!

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

忙しい人のためのCore Haptics

はじめに

iOS10からUIKitにはUIFeedbackGeneratorというAPIが追加されており、簡単にHapticFeedbackを実装することができました。

iOS13が公開され、UIFeedbackGeneratorに比べてカスタマイズできるHaptic Feedbackを作成できるようになったのですが、UIFeedbackGeneratorがあるUIKitにではなく、新しくCore HapticsというFrameworkが追加されました。

この記事はCore Hapticsとはどういう物なのか、実装方法などを簡単にまとめてみました。

HapticFeedbackとは

UISwitchでON/OFFを切り替えたり、UISliderでSliderを端までスワイプしたり、UIDatePickerでスクロールした時などの特定のアクションを行うと起きる振動のことで、たくさんの場所に組み込まれており、いくつかの種類が存在します

Core Hapticsとは

対応機種

iPhone8以降の全機種で使用することができます。

UIFeedBackGeneratorとCore Haptics

UIFeedBackGeneratorはNotification,Impact,Selectionなど、アクションを起こしたユーザーに対してFeedBackを行うことにより、UXを向上させることが目的とされています。

対して、Core Hapticsは自分でHapticFeedBackを内容、使うタイミングなどを自由に設定したい場合に使用します。
他のAPI(Core Animation, AVAudioEngine)との連動も可能なことや、Core HapticsはAudioAPIであり、今までAppleが実装していた音と連動して使用することなど(例:UIDatePickerのスクロール)も可能なので、UIFeedBackGeneratorに比べて豊かな表現を行うことができます。

WWDCでは特に活用できるジャンルとして、ゲーム、ARなどが挙げられていました・

スクリーンショット 2019-12-14 17.03.18.pngスクリーンショット 2019-12-14 17.03.39.png

コード

それでは実際に実装しながら見ていきたいと思います。

モジュールのインポート

import CoreHaptics

HapticEngine作成

var hapticEngine: CHHapticEngine?

do {
    hapticEngine = try CHHapticEngine()
    try hapticEngine?.start()
} catch {
    print("Error \(error)")
}

・CHHapticEngineにはcapabilitiesForHardwareというメソッドが存在しており、HapticEngineを作成する前にデバイスが実行されているデバイス環境でHapticsFeedBackがサポートされているか確認することもできます。

guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }

・そのほかにもstoppedHandler,resetHandlerが用意されており、アプリケーション以外の動作でエンジンが終了される時などに使用できるコールバックも用意されています。

var stoppedHandler: CHHapticEngine.StoppedHandler { get set }

var resetHandler: CHHapticEngine.ResetHandler { get set }

HapticEventParameterを元にHapticEventを作成

再生するコンテンツを作成します

//EventParameterを作成
let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: 1)
let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: 1)

//EventParameter,EventType,Time,からEventを作成します。
let event = CHHapticEvent(eventType: .hapticTransient, parameters: [intensity, sharpness], relativeTime: 0)

EventParameterを作成

・Eventで使われるParameterを作成します、ParameterTypeとしては音の強さ、明瞭さなど複数の種類が存在します。

EventParameter,EventType,Time,からEventを作成します。

・先ほど作成したEventParameter,EventType,Time,からEventを作成、eventTypeとしては単発で短い動き、継続的、音とHapticFeedBackを連動させるなどがあります。

再生

do {
    //先ほど作ったEventからPatternを作成
    let hapticPattern = try CHHapticPattern(events: [event], parameters: [])
    //PatternのPlayerを作成
    let hapticPlayer = try hapticEngine?.makePlayer(with: hapticPattern)
    try hapticPlayer?.start(atTime: CHHapticTimeImmediate)
} catch let error{
    print("Error: \(error)")
}

・イベントを1つのPatternとしてラップして、そのPatternに対するPlayerを作成します。

これがCoreHapticsの基本的なコードになります

Dynamic Parameter

UIFeedBackGeneratorとの違いの1つとして、Event Parameterの値を増減することや複数の動きを組み合わせる事が可能です。

・上記のCHHapticEventは単純な振動のみですが、下記のようにするとrelativeTimeを元にタイムラインを設定することにより、複数イベントを作成して、パターンとして扱うこともできます。

//単発のEvent
let short1 = CHHapticEvent(eventType: .hapticTransient, parameters: [], relativeTime: 0)
let short2 = CHHapticEvent(eventType: .hapticTransient, parameters: [], relativeTime: 0.2)
let short3 = CHHapticEvent(eventType: .hapticTransient, parameters: [], relativeTime: 0.4)
let short4 = CHHapticEvent(eventType: .hapticTransient, parameters: [], relativeTime: 0.6)
let short5 = CHHapticEvent(eventType: .hapticTransient, parameters: [], relativeTime: 0.8)
let short6 = CHHapticEvent(eventType: .hapticTransient, parameters: [], relativeTime: 1.0)

//継続的なEvent
let long1 = CHHapticEvent(eventType: .hapticContinuous, parameters: [], relativeTime: 1.2, duration: 0.5)
let long2 = CHHapticEvent(eventType: .hapticContinuous, parameters: [], relativeTime: 1.8, duration: 0.5)
let long3 = CHHapticEvent(eventType: .hapticContinuous, parameters: [], relativeTime: 2.4, duration: 0.5)

最後に

今回紹介したのはCore Hapticsの簡単な部分にすぎません、他のPatternの作成方法として、APHP(Apple Haptic Audio Pattern)と呼ばれるファイルを用いてコードからHapticPatternを分離させる方法や、CHHapticParameterCurveといった再生中に値を増減させるなどより自由度の高い作成方法もありますので、気になった方はWWDCの動画やDocumentなどをご覧になってみてください。

ありがとうございました。

参考リンク

Haptics - Human Interface Guidelines

Core Haptics

Introducing Core Haptics

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

Swiftは / 2 より * 0.5 のほうが速いのか

この記事は、CAM Advent Calendar 15日目の記事です:golf:
前回は @matsuhei さんによる、 レガシーコードに対する解析ツール(重複コード編)でした。

概要

以前、Swiftのコードを誰かに見せてたときに以下のように指摘されました。

ここ、 / 2 になっとるけど * 0.5 のほうが速いで

確かに、基本的には割り算よりも掛け算のほうがパフォーマンスがいいです。直に頭で計算するとき、僕も掛け算のほうが計算しやすいです。計算理由にもよりますが、Swiftもその例から漏れないはずです。

ただ、iOSでは本番用のipaを作成するときにはリリースビルド(本番)が行われます。
最適化の側面から見て、コードが一緒になってないかな?と思ったので、検証してみました。

SIL

Swift Compiler は以下のようになっています。
SS 2019-12-14 14.05.03.png

注目すべきはSILです。
SILは Swift Intermediate Language の略で、実行できるバイナリーに落とし込むために必要な中間言語です。
Swift CompilerはLLVM IRに落とし込む前にSILを生成します。
SILは主に2種類、 raw SILcanonical SIL があります。
raw SIL は、AST(abstract syntax tree)から解析しやすいように変換された直後のものです。
canonical SIL は、 raw SIL を最適化された状態の SILです。

検証1

sample1.swift
let aaa: Double = 200
let result: Double = aaa * 0.5
sample2.swift
let aaa: Double = 200
let result: Double = aaa / 2

実行コード

$ swiftc -emit-sil -O sample1.swift > sample1.sil
$ swiftc -emit-sil -O sample2.swift > sample2.sil

-emit-silcanonical SIL が生成されます。raw SIL を生成するには、 -emit-silgen と書けばよいです。
-O が付いていますが、これは canonical SIL からまた最適化が施された状態のものを生成するために必要なオプションで、ついてない場合は、 -Onone と同等です。

結果

sample1.sil
sil_stage canonical

import Builtin
import Swift
import SwiftShims

@_hasStorage @_hasInitialValue let aaa: Double { get }

@_hasStorage @_hasInitialValue let result: Double { get }

// aaa
sil_global hidden [let] @$s7sample53aaaSdvp : $Double

// result
sil_global hidden [let] @$s7sample56resultSdvp : $Double

// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  alloc_global @$s7sample53aaaSdvp                // id: %2
  %3 = global_addr @$s7sample53aaaSdvp : $*Double // user: %6
  %4 = float_literal $Builtin.FPIEEE64, 0x4069000000000000 // 200 // user: %5
  %5 = struct $Double (%4 : $Builtin.FPIEEE64)    // user: %6
  store %5 to %3 : $*Double                       // id: %6
  alloc_global @$s7sample56resultSdvp             // id: %7
  %8 = global_addr @$s7sample56resultSdvp : $*Double // user: %11
  %9 = float_literal $Builtin.FPIEEE64, 0x4059000000000000 // 100 // user: %10
  %10 = struct $Double (%9 : $Builtin.FPIEEE64)   // user: %11
  store %10 to %8 : $*Double                      // id: %11
  %12 = integer_literal $Builtin.Int32, 0         // user: %13
  %13 = struct $Int32 (%12 : $Builtin.Int32)      // user: %14
  return %13 : $Int32                             // id: %14
} // end sil function 'main'
sample2.sil
sil_stage canonical

import Builtin
import Swift
import SwiftShims

@_hasStorage @_hasInitialValue let aaa: Double { get }

@_hasStorage @_hasInitialValue let result: Double { get }

// aaa
sil_global hidden [let] @$s7sample63aaaSdvp : $Double

// result
sil_global hidden [let] @$s7sample66resultSdvp : $Double

// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  alloc_global @$s7sample63aaaSdvp                // id: %2
  %3 = global_addr @$s7sample63aaaSdvp : $*Double // user: %6
  %4 = float_literal $Builtin.FPIEEE64, 0x4069000000000000 // 200 // user: %5
  %5 = struct $Double (%4 : $Builtin.FPIEEE64)    // user: %6
  store %5 to %3 : $*Double                       // id: %6
  alloc_global @$s7sample66resultSdvp             // id: %7
  %8 = global_addr @$s7sample66resultSdvp : $*Double // user: %11
  %9 = float_literal $Builtin.FPIEEE64, 0x4059000000000000 // 100 // user: %10
  %10 = struct $Double (%9 : $Builtin.FPIEEE64)   // user: %11
  store %10 to %8 : $*Double                      // id: %11
  %12 = integer_literal $Builtin.Int32, 0         // user: %13
  %13 = struct $Int32 (%12 : $Builtin.Int32)      // user: %14
  return %13 : $Int32                             // id: %14
} // end sil function 'main'

Diff

@@ -9,21 +9,21 @@
 @_hasStorage @_hasInitialValue let result: Double { get }

 // aaa
-sil_global hidden [let] @$s7sample53aaaSdvp : $Double
+sil_global hidden [let] @$s7sample63aaaSdvp : $Double

 // result
-sil_global hidden [let] @$s7sample56resultSdvp : $Double
+sil_global hidden [let] @$s7sample66resultSdvp : $Double

 // main
 sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
 bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
-  alloc_global @$s7sample53aaaSdvp                // id: %2
-  %3 = global_addr @$s7sample53aaaSdvp : $*Double // user: %6
+  alloc_global @$s7sample63aaaSdvp                // id: %2
+  %3 = global_addr @$s7sample63aaaSdvp : $*Double // user: %6
   %4 = float_literal $Builtin.FPIEEE64, 0x4069000000000000 // 200 // user: %5
   %5 = struct $Double (%4 : $Builtin.FPIEEE64)    // user: %6
   store %5 to %3 : $*Double                       // id: %6
-  alloc_global @$s7sample56resultSdvp             // id: %7
-  %8 = global_addr @$s7sample56resultSdvp : $*Double // user: %11
+  alloc_global @$s7sample66resultSdvp             // id: %7
+  %8 = global_addr @$s7sample66resultSdvp : $*Double // user: %11
   %9 = float_literal $Builtin.FPIEEE64, 0x4059000000000000 // 100 // user: %10
   %10 = struct $Double (%9 : $Builtin.FPIEEE64)   // user: %11
   store %10 to %8 : $*Double                      // id: %11

float_literal $Builtin.FPIEEE64, 0x4059000000000000 // 100
これ、 200 / 2 した 100 を直接 result に突っ込んでますね……。
最適化された結果、SIL時点で実行時に計算されるのではなく結果のみを返されるようになりました。
リテラル値で計算されるから良いように最適化されたのかなと思ったので、検証コードを変更してみます。

検証2

sample3.swift
func test(_ value: Double) -> Double {
  return value * 0.5
}
let result: Double = test(200.0)
sample4.swift
func test(_ value: Double) -> Double {
  return value / 2
}
let result: Double = test(200.0)

結果

sample3.sil
sil_stage canonical

import Builtin
import Swift
import SwiftShims

func test(_ value: Double) -> Double

@_hasStorage @_hasInitialValue let result: Double { get }

// result
sil_global hidden [let] @$s7sample36resultSdvp : $Double

// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  alloc_global @$s7sample36resultSdvp             // id: %2
  %3 = global_addr @$s7sample36resultSdvp : $*Double // user: %6
  %4 = float_literal $Builtin.FPIEEE64, 0x4059000000000000 // 100 // user: %5
  %5 = struct $Double (%4 : $Builtin.FPIEEE64)    // user: %6
  store %5 to %3 : $*Double                       // id: %6
  %7 = integer_literal $Builtin.Int32, 0          // user: %8
  %8 = struct $Int32 (%7 : $Builtin.Int32)        // user: %9
  return %8 : $Int32                              // id: %9
} // end sil function 'main'

// test(_:)
sil hidden @$s7sample34testyS2dF : $@convention(thin) (Double) -> Double {
// %0                                             // users: %3, %1
bb0(%0 : $Double):
  debug_value %0 : $Double, let, name "value", argno 1 // id: %1
  %2 = float_literal $Builtin.FPIEEE64, 0x3FE0000000000000 // 0.5 // user: %4
  %3 = struct_extract %0 : $Double, #Double._value // user: %4
  %4 = builtin "fmul_FPIEEE64"(%3 : $Builtin.FPIEEE64, %2 : $Builtin.FPIEEE64) : $Builtin.FPIEEE64 // user: %5
  %5 = struct $Double (%4 : $Builtin.FPIEEE64)    // user: %6
  return %5 : $Double                             // id: %6
} // end sil function '$s7sample34testyS2dF'
sample4.sil
sil_stage canonical

import Builtin
import Swift
import SwiftShims

func test(_ value: Double) -> Double

@_hasStorage @_hasInitialValue let result: Double { get }

// result
sil_global hidden [let] @$s7sample46resultSdvp : $Double

// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  alloc_global @$s7sample46resultSdvp             // id: %2
  %3 = global_addr @$s7sample46resultSdvp : $*Double // user: %6
  %4 = float_literal $Builtin.FPIEEE64, 0x4059000000000000 // 100 // user: %5
  %5 = struct $Double (%4 : $Builtin.FPIEEE64)    // user: %6
  store %5 to %3 : $*Double                       // id: %6
  %7 = integer_literal $Builtin.Int32, 0          // user: %8
  %8 = struct $Int32 (%7 : $Builtin.Int32)        // user: %9
  return %8 : $Int32                              // id: %9
} // end sil function 'main'

// test(_:)
sil hidden @$s7sample44testyS2dF : $@convention(thin) (Double) -> Double {
// %0                                             // users: %3, %1
bb0(%0 : $Double):
  debug_value %0 : $Double, let, name "value", argno 1 // id: %1
  %2 = float_literal $Builtin.FPIEEE64, 0x4000000000000000 // 2 // user: %4
  %3 = struct_extract %0 : $Double, #Double._value // user: %4
  %4 = builtin "fdiv_FPIEEE64"(%3 : $Builtin.FPIEEE64, %2 : $Builtin.FPIEEE64) : $Builtin.FPIEEE64 // user: %5
  %5 = struct $Double (%4 : $Builtin.FPIEEE64)    // user: %6
  return %5 : $Double                             // id: %6
} // end sil function '$s7sample44testyS2dF'

Diff

@@ -9,13 +9,13 @@
 @_hasStorage @_hasInitialValue let result: Double { get }

 // result
-sil_global hidden [let] @$s7sample36resultSdvp : $Double
+sil_global hidden [let] @$s7sample46resultSdvp : $Double

 // main
 sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
 bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
-  alloc_global @$s7sample36resultSdvp             // id: %2
-  %3 = global_addr @$s7sample36resultSdvp : $*Double // user: %6
+  alloc_global @$s7sample46resultSdvp             // id: %2
+  %3 = global_addr @$s7sample46resultSdvp : $*Double // user: %6
   %4 = float_literal $Builtin.FPIEEE64, 0x4059000000000000 // 100 // user: %5
   %5 = struct $Double (%4 : $Builtin.FPIEEE64)    // user: %6
   store %5 to %3 : $*Double                       // id: %6
@@ -25,16 +25,16 @@
 } // end sil function 'main'

 // test(_:)
-sil hidden @$s7sample34testyS2dF : $@convention(thin) (Double) -> Double {
+sil hidden @$s7sample44testyS2dF : $@convention(thin) (Double) -> Double {
 // %0                                             // users: %3, %1
 bb0(%0 : $Double):
   debug_value %0 : $Double, let, name "value", argno 1 // id: %1
-  %2 = float_literal $Builtin.FPIEEE64, 0x3FE0000000000000 // 0.5 // user: %4
+  %2 = float_literal $Builtin.FPIEEE64, 0x4000000000000000 // 2 // user: %4
   %3 = struct_extract %0 : $Double, #Double._value // user: %4
-  %4 = builtin "fmul_FPIEEE64"(%3 : $Builtin.FPIEEE64, %2 : $Builtin.FPIEEE64) : $Builtin.FPIEEE64 // user: %5
+  %4 = builtin "fdiv_FPIEEE64"(%3 : $Builtin.FPIEEE64, %2 : $Builtin.FPIEEE64) : $Builtin.FPIEEE64 // user: %5
   %5 = struct $Double (%4 : $Builtin.FPIEEE64)    // user: %6
   return %5 : $Double                             // id: %6
-} // end sil function '$s7sample34testyS2dF'
+} // end sil function '$s7sample44testyS2dF'
%4 = builtin "fmul_FPIEEE64"(%3 : $Builtin.FPIEEE64, %2 : $Builtin.FPIEEE64) : $Builtin.FPIEEE64 // user: %5
%4 = builtin "fdiv_FPIEEE64"(%3 : $Builtin.FPIEEE64, %2 : $Builtin.FPIEEE64) : $Builtin.FPIEEE64 // user: %5

割り算と掛け算がちゃんと別れています。
/ 2 を自動的に * 0.5 に変換される、なんてことはないようです。
ちなみに、test(:)@inlinable を追加したら、検証1と同じ計算した結果の100の値が保持されました。展開されて、最適化が施されたようです。

まとめ

  • リテラル値同士の計算は 最適化されて計算した結果のみ保持する

これを色々調べて思ったのは、ただ早いから * 0.5 を選ぶのは軽率かなと思いました。可読性の問題もあったり、上の最適化されて結果同じ場合もあったりするので、そこらへんを正しく見極めて書いていくことが大事だと感じました。

次は16日目、@keitatata による redis レプリケーションとシャーディング です。お楽しみに。

参考資料

以下の資料は今回の記事を書く上で非常に参考になりました。ありがとうございます。

http://llvm.org/devmtg/2015-10/slides/GroffLattner-SILHighLevelIR.pdf
https://blog.waft.me/2018/01/09/swift-sil-1/
https://github.com/apple/swift/blob/master/docs/SIL.rst
https://qiita.com/Kuniwak/items/cbf6b88db249838895b5

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

最適化から見る、Swiftの / 2 と * 0.5

この記事は、CAM Advent Calendar 15日目の記事です:golf:
前回は @matsuhei さんによる、 レガシーコードに対する解析ツール(重複コード編)でした。

概要

以前、Swiftのコードを誰かに見せてたときに以下のように指摘されました。

ここ、 / 2 になっとるけど * 0.5 のほうが速いで

確かに、基本的には割り算よりも掛け算のほうがパフォーマンスがいいです。直に頭で計算するとき、僕も掛け算のほうが計算しやすいです。計算理由にもよりますが、Swiftもその例から漏れないはずです。

ただ、iOSでは本番用のipaを作成するときにはリリースビルド(本番)が行われます。
最適化の側面から見て、コードが一緒になってないかな?と思ったので、検証してみました。

SIL

Swift Compiler は以下のようになっています。
SS 2019-12-14 14.05.03.png

注目すべきはSILです。
SILは Swift Intermediate Language の略で、実行できるバイナリーに落とし込むために必要な中間言語です。
Swift CompilerはLLVM IRに落とし込む前にSILを生成します。
SILは主に2種類、 raw SILcanonical SIL があります。
raw SIL は、AST(abstract syntax tree)から解析しやすいように変換された直後のものです。
canonical SIL は、 raw SIL を最適化された状態の SILです。

検証1

sample1.swift
let aaa: Double = 200
let result: Double = aaa * 0.5
sample2.swift
let aaa: Double = 200
let result: Double = aaa / 2

実行コード

$ swiftc -emit-sil -O sample1.swift > sample1.sil
$ swiftc -emit-sil -O sample2.swift > sample2.sil

-emit-silcanonical SIL が生成されます。raw SIL を生成するには、 -emit-silgen と書けばよいです。
-O が付いていますが、これは canonical SIL からまた最適化が施された状態のものを生成するために必要なオプションで、ついてない場合は、 -Onone と同等です。

結果

sample1.sil
sil_stage canonical

import Builtin
import Swift
import SwiftShims

@_hasStorage @_hasInitialValue let aaa: Double { get }

@_hasStorage @_hasInitialValue let result: Double { get }

// aaa
sil_global hidden [let] @$s7sample53aaaSdvp : $Double

// result
sil_global hidden [let] @$s7sample56resultSdvp : $Double

// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  alloc_global @$s7sample53aaaSdvp                // id: %2
  %3 = global_addr @$s7sample53aaaSdvp : $*Double // user: %6
  %4 = float_literal $Builtin.FPIEEE64, 0x4069000000000000 // 200 // user: %5
  %5 = struct $Double (%4 : $Builtin.FPIEEE64)    // user: %6
  store %5 to %3 : $*Double                       // id: %6
  alloc_global @$s7sample56resultSdvp             // id: %7
  %8 = global_addr @$s7sample56resultSdvp : $*Double // user: %11
  %9 = float_literal $Builtin.FPIEEE64, 0x4059000000000000 // 100 // user: %10
  %10 = struct $Double (%9 : $Builtin.FPIEEE64)   // user: %11
  store %10 to %8 : $*Double                      // id: %11
  %12 = integer_literal $Builtin.Int32, 0         // user: %13
  %13 = struct $Int32 (%12 : $Builtin.Int32)      // user: %14
  return %13 : $Int32                             // id: %14
} // end sil function 'main'
sample2.sil
sil_stage canonical

import Builtin
import Swift
import SwiftShims

@_hasStorage @_hasInitialValue let aaa: Double { get }

@_hasStorage @_hasInitialValue let result: Double { get }

// aaa
sil_global hidden [let] @$s7sample63aaaSdvp : $Double

// result
sil_global hidden [let] @$s7sample66resultSdvp : $Double

// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  alloc_global @$s7sample63aaaSdvp                // id: %2
  %3 = global_addr @$s7sample63aaaSdvp : $*Double // user: %6
  %4 = float_literal $Builtin.FPIEEE64, 0x4069000000000000 // 200 // user: %5
  %5 = struct $Double (%4 : $Builtin.FPIEEE64)    // user: %6
  store %5 to %3 : $*Double                       // id: %6
  alloc_global @$s7sample66resultSdvp             // id: %7
  %8 = global_addr @$s7sample66resultSdvp : $*Double // user: %11
  %9 = float_literal $Builtin.FPIEEE64, 0x4059000000000000 // 100 // user: %10
  %10 = struct $Double (%9 : $Builtin.FPIEEE64)   // user: %11
  store %10 to %8 : $*Double                      // id: %11
  %12 = integer_literal $Builtin.Int32, 0         // user: %13
  %13 = struct $Int32 (%12 : $Builtin.Int32)      // user: %14
  return %13 : $Int32                             // id: %14
} // end sil function 'main'

Diff

@@ -9,21 +9,21 @@
 @_hasStorage @_hasInitialValue let result: Double { get }

 // aaa
-sil_global hidden [let] @$s7sample53aaaSdvp : $Double
+sil_global hidden [let] @$s7sample63aaaSdvp : $Double

 // result
-sil_global hidden [let] @$s7sample56resultSdvp : $Double
+sil_global hidden [let] @$s7sample66resultSdvp : $Double

 // main
 sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
 bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
-  alloc_global @$s7sample53aaaSdvp                // id: %2
-  %3 = global_addr @$s7sample53aaaSdvp : $*Double // user: %6
+  alloc_global @$s7sample63aaaSdvp                // id: %2
+  %3 = global_addr @$s7sample63aaaSdvp : $*Double // user: %6
   %4 = float_literal $Builtin.FPIEEE64, 0x4069000000000000 // 200 // user: %5
   %5 = struct $Double (%4 : $Builtin.FPIEEE64)    // user: %6
   store %5 to %3 : $*Double                       // id: %6
-  alloc_global @$s7sample56resultSdvp             // id: %7
-  %8 = global_addr @$s7sample56resultSdvp : $*Double // user: %11
+  alloc_global @$s7sample66resultSdvp             // id: %7
+  %8 = global_addr @$s7sample66resultSdvp : $*Double // user: %11
   %9 = float_literal $Builtin.FPIEEE64, 0x4059000000000000 // 100 // user: %10
   %10 = struct $Double (%9 : $Builtin.FPIEEE64)   // user: %11
   store %10 to %8 : $*Double                      // id: %11

float_literal $Builtin.FPIEEE64, 0x4059000000000000 // 100
これ、 200 / 2 した 100 を直接 result に突っ込んでますね……。
最適化された結果、SIL時点で実行時に計算されるのではなく結果のみを返されるようになりました。
リテラル値で計算されるから良いように最適化されたのかなと思ったので、検証コードを変更してみます。

検証2

sample3.swift
func test(_ value: Double) -> Double {
  return value * 0.5
}
let result: Double = test(200.0)
sample4.swift
func test(_ value: Double) -> Double {
  return value / 2
}
let result: Double = test(200.0)

結果

sample3.sil
sil_stage canonical

import Builtin
import Swift
import SwiftShims

func test(_ value: Double) -> Double

@_hasStorage @_hasInitialValue let result: Double { get }

// result
sil_global hidden [let] @$s7sample36resultSdvp : $Double

// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  alloc_global @$s7sample36resultSdvp             // id: %2
  %3 = global_addr @$s7sample36resultSdvp : $*Double // user: %6
  %4 = float_literal $Builtin.FPIEEE64, 0x4059000000000000 // 100 // user: %5
  %5 = struct $Double (%4 : $Builtin.FPIEEE64)    // user: %6
  store %5 to %3 : $*Double                       // id: %6
  %7 = integer_literal $Builtin.Int32, 0          // user: %8
  %8 = struct $Int32 (%7 : $Builtin.Int32)        // user: %9
  return %8 : $Int32                              // id: %9
} // end sil function 'main'

// test(_:)
sil hidden @$s7sample34testyS2dF : $@convention(thin) (Double) -> Double {
// %0                                             // users: %3, %1
bb0(%0 : $Double):
  debug_value %0 : $Double, let, name "value", argno 1 // id: %1
  %2 = float_literal $Builtin.FPIEEE64, 0x3FE0000000000000 // 0.5 // user: %4
  %3 = struct_extract %0 : $Double, #Double._value // user: %4
  %4 = builtin "fmul_FPIEEE64"(%3 : $Builtin.FPIEEE64, %2 : $Builtin.FPIEEE64) : $Builtin.FPIEEE64 // user: %5
  %5 = struct $Double (%4 : $Builtin.FPIEEE64)    // user: %6
  return %5 : $Double                             // id: %6
} // end sil function '$s7sample34testyS2dF'
sample4.sil
sil_stage canonical

import Builtin
import Swift
import SwiftShims

func test(_ value: Double) -> Double

@_hasStorage @_hasInitialValue let result: Double { get }

// result
sil_global hidden [let] @$s7sample46resultSdvp : $Double

// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  alloc_global @$s7sample46resultSdvp             // id: %2
  %3 = global_addr @$s7sample46resultSdvp : $*Double // user: %6
  %4 = float_literal $Builtin.FPIEEE64, 0x4059000000000000 // 100 // user: %5
  %5 = struct $Double (%4 : $Builtin.FPIEEE64)    // user: %6
  store %5 to %3 : $*Double                       // id: %6
  %7 = integer_literal $Builtin.Int32, 0          // user: %8
  %8 = struct $Int32 (%7 : $Builtin.Int32)        // user: %9
  return %8 : $Int32                              // id: %9
} // end sil function 'main'

// test(_:)
sil hidden @$s7sample44testyS2dF : $@convention(thin) (Double) -> Double {
// %0                                             // users: %3, %1
bb0(%0 : $Double):
  debug_value %0 : $Double, let, name "value", argno 1 // id: %1
  %2 = float_literal $Builtin.FPIEEE64, 0x4000000000000000 // 2 // user: %4
  %3 = struct_extract %0 : $Double, #Double._value // user: %4
  %4 = builtin "fdiv_FPIEEE64"(%3 : $Builtin.FPIEEE64, %2 : $Builtin.FPIEEE64) : $Builtin.FPIEEE64 // user: %5
  %5 = struct $Double (%4 : $Builtin.FPIEEE64)    // user: %6
  return %5 : $Double                             // id: %6
} // end sil function '$s7sample44testyS2dF'

Diff

@@ -9,13 +9,13 @@
 @_hasStorage @_hasInitialValue let result: Double { get }

 // result
-sil_global hidden [let] @$s7sample36resultSdvp : $Double
+sil_global hidden [let] @$s7sample46resultSdvp : $Double

 // main
 sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
 bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
-  alloc_global @$s7sample36resultSdvp             // id: %2
-  %3 = global_addr @$s7sample36resultSdvp : $*Double // user: %6
+  alloc_global @$s7sample46resultSdvp             // id: %2
+  %3 = global_addr @$s7sample46resultSdvp : $*Double // user: %6
   %4 = float_literal $Builtin.FPIEEE64, 0x4059000000000000 // 100 // user: %5
   %5 = struct $Double (%4 : $Builtin.FPIEEE64)    // user: %6
   store %5 to %3 : $*Double                       // id: %6
@@ -25,16 +25,16 @@
 } // end sil function 'main'

 // test(_:)
-sil hidden @$s7sample34testyS2dF : $@convention(thin) (Double) -> Double {
+sil hidden @$s7sample44testyS2dF : $@convention(thin) (Double) -> Double {
 // %0                                             // users: %3, %1
 bb0(%0 : $Double):
   debug_value %0 : $Double, let, name "value", argno 1 // id: %1
-  %2 = float_literal $Builtin.FPIEEE64, 0x3FE0000000000000 // 0.5 // user: %4
+  %2 = float_literal $Builtin.FPIEEE64, 0x4000000000000000 // 2 // user: %4
   %3 = struct_extract %0 : $Double, #Double._value // user: %4
-  %4 = builtin "fmul_FPIEEE64"(%3 : $Builtin.FPIEEE64, %2 : $Builtin.FPIEEE64) : $Builtin.FPIEEE64 // user: %5
+  %4 = builtin "fdiv_FPIEEE64"(%3 : $Builtin.FPIEEE64, %2 : $Builtin.FPIEEE64) : $Builtin.FPIEEE64 // user: %5
   %5 = struct $Double (%4 : $Builtin.FPIEEE64)    // user: %6
   return %5 : $Double                             // id: %6
-} // end sil function '$s7sample34testyS2dF'
+} // end sil function '$s7sample44testyS2dF'
%4 = builtin "fmul_FPIEEE64"(%3 : $Builtin.FPIEEE64, %2 : $Builtin.FPIEEE64) : $Builtin.FPIEEE64 // user: %5
%4 = builtin "fdiv_FPIEEE64"(%3 : $Builtin.FPIEEE64, %2 : $Builtin.FPIEEE64) : $Builtin.FPIEEE64 // user: %5

割り算と掛け算がちゃんと別れています。
/ 2 を自動的に * 0.5 に変換される、なんてことはないようです。
ちなみに、test(:)@inlinable を追加したら、検証1と同じ計算した結果の100の値が保持されました。展開されて、最適化が施されたようです。

まとめ

  • リテラル値同士の計算は 最適化されて計算した結果のみ保持する

これを色々調べて思ったのは、ただ早いから * 0.5 を選ぶのは軽率かなと思いました。可読性の問題もあったり、上の最適化されて結果同じ場合もあったりするので、そこらへんを正しく見極めて書いていくことが大事だと感じました。

次は16日目、@keitatata による redis レプリケーションとシャーディング です。お楽しみに。

参考資料

以下の資料は今回の記事を書く上で非常に参考になりました。ありがとうございます。

http://llvm.org/devmtg/2015-10/slides/GroffLattner-SILHighLevelIR.pdf
https://blog.waft.me/2018/01/09/swift-sil-1/
https://github.com/apple/swift/blob/master/docs/SIL.rst
https://qiita.com/Kuniwak/items/cbf6b88db249838895b5

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