- 投稿日:2020-05-16T21:21:49+09:00
iOSリバーシリファクタリングチャレンジ w/ Redux
リバーシリファクタリングチャレンジ
koherさんが公開された、このFat View Controller、あなたはリファクタリングできますか?チャレンジに参加しました。
本チャレンジは、 Fat View Controller として実装されたリバーシアプリをリファクタリングし、どれだけクリーンな設計とコードを実現できるかというコンペティションです(ジャッジが優劣を判定するわけではなく、設計の技を競い合うのが目的です)。
すばらしいチャレンジを用意くださったkoherさんを始め、運営のお手伝いをされているtakasekさん、Ogawaさんの皆様に感謝です。
リファクタリング結果
以下のGitHubリポジトリにリファクタリングした結果を公開しています。masterブランチがリファクタリング済みになります。
この記事にもコードを記載していますが全体の8割ぐらいです。リファクタリング方針
ゼロから作り直すやり方ではなく、オリジナルのリバーシ部分のロジックを踏襲しつつ、少しずつ動作を確認しながらリファクタリングを実施しました。
iOSアプリ設計パターン入門にて、Reduxアーキテクチャの章を執筆したので、Reduxアーキテクチャを適用したリファクタリングにチャレンジします。ターン制のゲームはユーザーやコンピュータのアクションによりゲームの状態が変化し、状態の変化に合わせたビューの表示を行う流れは、Reduxアーキテクチャと相性が良いと感じました。
リファクタリング観点
以下のような観点を意識しながら、リファクタリングを行いました。
ただ、各観点を最大化することが目的ではなく、適切な範囲にとどめ全体最適を目指すことが大事だと思っています。
- 階層、分断、排他、網羅を意識した構造化
- 影響範囲の局所化
- 型表現や命名による意味付け
- 単一責務化
- 制約化
- 抽象化
- 共通化
アーキテクチャ導入
Reduxアーキテクチャ構造
リバーシアプリに適用したReduxアーキテクチャの構造は以下の図のようになります。
また、Reduxアーキテクチャを支援するReSwiftライブラリを導入しました。Reduxは状態の変化を単一のフロー制御により制約付けられており、ViewからはActionをStoreにdispatchし、Reducer関数によってStateが変更されます。ViewはStoreから変更されたことが通知されるので、イミュータブルなStateにアクセスし前回のStateから変化した状態を割り出して適切なViewの表示を更新したり、Stateの状態を鑑みて新たなActionをStoreにdispatchしたりします。
レイヤ分割(Xcodeターゲット分割)
Reduxアーキテクチャを導入するにあたりターゲットを以下の2つに分割しました。
もっと規模が大きいアプリの場合は、LogicレイヤをCleanアーキテクチャを参考にレイヤ分割してもよいかもしれません。
- Reversiターゲット(ビューに関するコード)
- UIKit ViewController
- UIKit View
- Logicターゲット(ロジックに関するコード)
- Redux actions
- Redux store
- Redux state
- Redux reducer
依存関係は以下のとおりです。
- Reversiターゲット ⇒ Logicターゲット
Logicターゲットは以下のような目的で設けました。
- Viewのコードに依存しないビジネスロジックのコードを局所化するため
- UIKitに依存しないPureSwiftのコードを局所化するため
- ビジネスロジックのコードのViewに対する可視性を細かく制御(publicとinternalの使い分け)したかったため
可視性制御(publicとinternalの使い分け)
- stateやdataのstruct/classはロジック側でのみinitできるように制約する
- ⇒ View側で意図せず状態が生成され利用されることを抑止する
- internalなプロパティはテストコードからでも参照できるので、より詳細な内部の状態の確認に利用する
DataTypeの抽出
- 責務過多なデータ表現は用途を限定した単一責務のデータに分割する
- e.g.
Disk
andSide
- 役割を担うプリミティブなデータ構造をデータ型と命名を与え意味付する
- e.g.
Coordinate
,PlacedDiskCoordinate
,OptionalDiskCoordinate
- 有限なデータを可変長[配列]で表現している箇所は有限で表現する
- ビューのインスタンスが保持しているデータをDataTypeとして抽出する
既存のDataType
public enum Player: Int, Equatable, Codable { case manual = 0 case computer = 1 } public enum Disk: String, Codable { case diskDark case diskLight }追加したDataType
public enum Side: String, Codable, CaseIterable { case sideDark case sideLight } extension Side: Hashable {} extension Side { public var index: Int { switch self { case .sideDark: return 0 case .sideLight: return 1 } } public var disk: Disk { switch self { case .sideDark: return .diskDark case .sideLight: return .diskLight } } } extension Side { var flipped: Side { switch self { case .sideDark: return .sideLight case .sideLight: return .sideDark } } } public struct Coordinate: Equatable, Codable { public var x: Int public var y: Int public init(x: Int, y: Int) { self.x = x self.y = y } } infix operator +: AdditionPrecedence extension Coordinate { static func + (left: Coordinate, right: Coordinate) -> Coordinate { return Coordinate(x: left.x + right.x, y: left.y + right.y) } } public struct PlacedDiskCoordinate: Equatable, Codable { public var disk: Disk public var coordinate: Coordinate public init(disk: Disk, coordinate: Coordinate) { self.disk = disk self.coordinate = coordinate } } extension PlacedDiskCoordinate { var optionalDiskCoordinate: OptionalDiskCoordinate { OptionalDiskCoordinate(disk: disk, coordinate: coordinate) } } public struct OptionalDiskCoordinate: Equatable, Codable { public var disk: Disk? public var coordinate: Coordinate } public struct BoardSetting: Equatable, Codable { public var cols: Int public var rows: Int private var xRange: Range<Int> { 0 ..< self.cols } private var yRange: Range<Int> { 0 ..< self.rows } public var coordinates: [Coordinate] { self.yRange.map { y in self.xRange.map { x in Coordinate(x: x, y: y) } }.flatMap { $0 } } public func validCoordinate(_ coordinate: Coordinate) -> Bool { self.xRange.contains(coordinate.x) && self.yRange.contains(coordinate.y) } init(cols: Int, rows: Int) { self.cols = cols self.rows = rows } }補足
データが
Equatable
に準拠しているのはView側でStateの変更通知を受け取り、Stateへアクセスしたときに前回の変更通知から、どこが変わったのかわからないため前回のStateと比較して差分を検知するために利用します。GameProgress(ゲーム進行状態表現)
ゲームの進行状態を表現する
GameProgress
を導入しました。
- このゲームの進行状態は有限でかつ排他的なのでenumで表現
- 各進行状態下に付随する取りうる状態をassociated valueを活用して包括関係を表現
public enum GameProgress: Equatable { case initialing case turn(Progress, Side, Player, ComputerThinking) case gameOver(GameOver) case interrupt(Interrupt) } public enum Side: String, Codable, CaseIterable { case sideDark case sideLight } public enum ComputerThinking: String, Equatable, Codable { case none case thinking } public enum GameOver: Equatable { case won(Side) case tied } public enum Interrupt: Equatable { case resetConfrmation(Alert) case cannotPlaceDisk(Alert) } public enum Alert: String, Equatable, Codable { case none case shouldShow case showing } public enum Progress: Equatable { case start case progressing }以下の表は
GameProgress
が取りうる主要な状態のパターンを表しています。
(注意:この表は便宜上のため、正しい包括関係と網羅性を担保できていません)
GameProgress Associated value1 Associated value2 Associated value3 initialing --- --- --- turn Side.dark Player.manual ComputerThinking.none 〃 〃 Player.computer ComputerThinking.none 〃 〃 〃 ComputerThinking.thinking 〃 Side.light Player.manual ComputerThinking.none 〃 〃 Player.computer ComputerThinking.none 〃 〃 〃 ComputerThinking.thinking gameOver GameOver.won Side.dark --- 〃 〃 Side.light --- 〃 GameOver.tied --- --- interrupt resetConfirmation Alert.none --- 〃 〃 Alert.shouldShow --- 〃 〃 Alert.showing --- 〃 cannotPlaceDisk Alert.none --- 〃 〃 Alert.shouldShow --- 〃 〃 Alert.showing --- interrup(割り込み状態表現)
アラートを表示中の状態管理が悩ましかったので
interrupt
状態を導入しゲーム中への割り込みを表現しています。
既存コードでは、リセットのアラートを表示中でも双方のプレイヤーがコンピュータであればゲームは進行します。
このとき、プレイヤーが手詰まりになると、手詰まりのアラートを表示するのですが、2つのアラート表示がバッティングするので対処が必要になります。本リファクタリングでは、リセットアラートを表示中はコンピュータによるゲームを進行しない方針としました。
interrupt
状態はturn
状態と排他的な状態で、ゲームの進行(コンピュータの思考)はturn
状態のみ実行できるものとすることで実現しました。また、
interrupt
は手詰まりのアラートをシステムから表示する場合も割り込みとして表現しました。
既存コード リファクタリングコード リセットアラートが表示中でもゲームが進行する リセットアラートが表示中だとゲームが進行しない Redux
AppStateとAcrtionのすべてのコードはGitHub repositoryで確認できます。
AppState
AppState
はViewから参照される状態の起点になるStateです。
GameProgress
はAppState
の内部状態から現在あるべきGameProgress
の状態をComputed propertyを用いて割り出しています。import Foundation import ReSwift public struct AppState: StateType, Codable { public var boardContainer: BoardContainer public var playerDark: PlayerSide = .init(side: .sideDark) public var playerLight: PlayerSide = .init(side: .sideLight) public var gameProgress: GameProgress { if isInitialing { return .initialing } else if cannotPlaceDiskAlert != .none { return .interrupt(.cannotPlaceDisk(cannotPlaceDiskAlert)) } else if resetConfirmationAlert != .none { return .interrupt(.resetConfirmation(resetConfirmationAlert)) } else if let side = side { let progress: Progress = turnStart ? .start : .progressing let player: Player switch side { case .sideDark: player = playerDark.player case .sideLight: player = playerLight.player } return .turn(progress, side, player, computerThinking) } else if let winnerSide = boardContainer.board.sideWithMoreDisks() { return .gameOver(.won(winnerSide)) } else { return .gameOver(.tied) } } var id: String = NSUUID().uuidString // prevent override uing reseted state var side: Side? = .sideDark var turnStart: Bool = false var isInitialing: Bool = true var isLoadedGame: Bool = false // prevent duplicate load game calls var computerThinking: ComputerThinking = .none var cannotPlaceDiskAlert: Alert = .none var resetConfirmationAlert: Alert = .none init(boardSetting: BoardSetting = .init(cols: 8, rows: 8)) { self.boardContainer = .init(diskCoordinatesState: Board(boardSetting: boardSetting)) } }Reducer
Reducer
はミューテーション可能なStateのコピーを一時的に生成し、新たな状態を反映させたうえ、イミュータブルなStateとして返す純粋関数です。
各Actionによって、どのように状態が変化するのか一目瞭然となっています。func reducer(action: Action, state: AppState?) -> AppState { var state = state ?? .init() if state.turnStart { state.turnStart = false } if let action = action as? AppAction { switch action { case .startGame: state.isInitialing = false case .placeDisk(let placedDiskCoordinate): let flippedDiskCoordinates = state.boardContainer.board.flippedDiskCoordinatesByPlacingDisk(placedDiskCoordinate) guard !flippedDiskCoordinates.isEmpty else { return state } let changed: BoardChanged = .init(placedDiskCoordinate: placedDiskCoordinate, flippedDiskCoordinates: flippedDiskCoordinates) changed.changedDiskCoordinate.forEach { state.boardContainer.board[$0.coordinate] = $0.optionalDiskCoordinate } state.boardContainer.changed = changed state.playerDark.count = state.boardContainer.board.count(of: .diskDark) state.playerLight.count = state.boardContainer.board.count(of: .diskLight) case .cannotPlaceDisk(let alert): state.cannotPlaceDiskAlert = alert case .resetConfirmation(let alert): state.resetConfirmationAlert = alert } } if let action = action as? AppPrivateAction { switch action { case .nextTurn: guard case .none = state.resetConfirmationAlert else { return state } guard let temp = state.side else { assertionFailure(); return state } state.cannotPlaceDiskAlert = .none let side = temp.flipped state.side = side case .validateTurn: guard let side = state.side else { return state } if state.boardContainer.board.validMoves(for: side).isEmpty { if state.boardContainer.board.validMoves(for: side.flipped).isEmpty { state.side = nil // GameOver } else { state.cannotPlaceDiskAlert = .shouldShow } } else { state.turnStart = true } case .changePlayer(let side, let player): switch side { case .sideDark: state.playerDark.player = player case .sideLight: state.playerLight.player = player } state.turnStart = true if side == state.side { state.computerThinking = .none } case .resetAllState: var newState = AppState() newState.playerDark = .init(side: .sideDark, count: newState.boardContainer.board.count(of: .diskDark)) newState.playerLight = .init(side: .sideLight, count: newState.boardContainer.board.count(of: .diskLight)) return newState case .finisedLoadGame(let loadedAppState): return loadedAppState case .finisedSaveGame: break case .startComputerThinking: state.computerThinking = .thinking case .endComputerThinking: state.computerThinking = .none } } return state }Action
Action
はenumで表現して、Reducerで処理を行うべき網羅性を担保しています。
PrivateAction
は、Viewからdispatchしない、ActionCreator
からのみdisptachするAction
として設けています。public enum AppAction: Action { case startGame case placeDisk(PlacedDiskCoordinate) case cannotPlaceDisk(Alert) case resetConfirmation(Alert) } enum AppPrivateAction: Action { case nextTurn case validateTurn case changePlayer(side: Side, player: Player) case resetAllState case finisedLoadGame(AppState) case startComputerThinking case endComputerThinking case finisedSaveGame } struct ErrorAction: Action { let error: Error let title: String let message: String }ActionCreator
ActionCreator
はReducer
のようにStateの変更を行いませんが、以下のようなロジックの処理を担っています。
- 副作用(ゲームデータの保存・読み込み)が発生する処理
- 条件によってdispatchしたいActionを変更したい処理
- 複数のActionをdispatchしたい処理
また、副作用を伴う処理に依存する部分は、テスタブルにするためDependency Injectionしています。
extension AppAction { public static func newGame() -> Thunk<AppState> { return Thunk<AppState> { dispatch, getState, dependency in print("- Logic.AppAction.newGame() START") dispatch(AppPrivateAction.resetAllState) dispatch(AppAction.saveGame()) print("- Logic.AppAction.newGame() END") } } public static func saveGame() -> Thunk<AppState> { return Thunk<AppState> { dispatch, getState, dependency in print("- Logic.AppAction.saveGame() START") do { guard var state = getState() else { preconditionFailure() } state.isInitialing = true state.boardContainer.changed = nil state.computerThinking = .none state.resetConfirmationAlert = .none try dependency.persistentInteractor.saveGame(state) dispatch(AppPrivateAction.finisedSaveGame) } catch let error { dump(error) let title = "Error occurred." let message = "Cannot save games." dispatch(ErrorAction(error: error, title: title, message: message)) } print("- Logic.AppAction.saveGame() END") } } public static func loadGame() -> Thunk<AppState> { return Thunk<AppState> { dispatch, getState, dependency in print("- Logic.AppAction.loadGame() START") do { guard let state = getState() else { preconditionFailure() } guard state.isLoadedGame == false else { return } // prevent duplicate load game calls dispatch(AppPrivateAction.resetAllState) let loadData = try dependency.persistentInteractor.loadGame() dispatch(AppPrivateAction.finisedLoadGame(loadData)) dispatch(AppPrivateAction.validateTurn) } catch let error { dump(error) dispatch(AppAction.newGame()) } print("- Logic.AppAction.loadGame() END") } } public static func nextTurn() -> Thunk<AppState> { return Thunk<AppState> { dispatch, getState, dependency in guard let state = getState() else { preconditionFailure() } if case .turn(_, let side, _, _) = state.gameProgress { print("- Logic.AppAction.nextTurn() from: \(side) to: \(side.flipped)") } dispatch(AppPrivateAction.nextTurn) dispatch(AppPrivateAction.validateTurn) } } public static func changePlayer(side: Side, player: Player) -> Thunk<AppState> { return Thunk<AppState> { dispatch, getState, dependency in print("- Logic.AppAction.changePlayer(side: \(side), player: \(player)) START") dispatch(AppPrivateAction.changePlayer(side: side, player: player)) dispatch(AppAction.saveGame()) print("- Logic.AppAction.changePlayer(side: \(side), player: \(player)) END") } } public static func waitForPlayer() -> Thunk<AppState> { return Thunk<AppState> { dispatch, getState, dependency in print("- Logic.AppAction.waitForPlayer() START") guard let state = getState() else { preconditionFailure() } switch state.gameProgress { case .turn(_, _, let player, _): switch player { case .manual: break case .computer: dispatch(AppAction.playTurnOfComputer()) } case .initialing, .interrupt, .gameOver: assertionFailure() } print("- Logic.AppAction.waitForPlayer() END") } } private static func playTurnOfComputer() -> Thunk<AppState> { return Thunk<AppState> { dispatch, getState, dependency in print("- Logic.AppAction.playTurnOfComputer() START") guard let state = getState() else { preconditionFailure() } switch state.gameProgress { case .turn(_, let side, _, _): let candidates = state.boardContainer.board.validMoves(for: side) switch candidates.isEmpty { case true: dispatch(AppAction.nextTurn()) case false: guard let candidate = candidates.randomElement() else { preconditionFailure() } let id = state.id store.dispatch(AppPrivateAction.startComputerThinking) DispatchQueue.main.asyncAfter(deadline: .now() + dependency.computerThinkingTime) { guard let state = getState() else { preconditionFailure() } guard id == state.id else { return } // maybe reset game guard case .turn(_, let sideEnd, _, let computerThinkingEnd) = state.gameProgress else { return } guard case .thinking = computerThinkingEnd, side == sideEnd else { return } // maybe chaned to manual player guard case .none = state.resetConfirmationAlert else { return } dispatch(AppPrivateAction.endComputerThinking) dispatch(AppAction.placeDisk(candidate)) } } case .initialing, .interrupt, .gameOver: assertionFailure() } print("- Logic.AppAction.playTurnOfComputer() END") } } }Dependency & Middleware
PersistentInteractor
,Repository
はDependency Injectionできようにすために、protocolで抽象化し、createThunkMiddleware
経由でDIしています。public let store = Store<AppState>( reducer: reducer, state: AppState(), middleware: [thunkMiddleware, loggingMiddleware] ) protocol Dependency { var persistentInteractor: PersistentInteractor { get } var computerThinkingTime: DispatchTimeInterval { get } } struct DependencyImpl: Dependency { let persistentInteractor: PersistentInteractor let computerThinkingTime: DispatchTimeInterval init(persistentInteractor: PersistentInteractor = PersistentInteractorImpl(), computerThinkingTime: DispatchTimeInterval = DispatchTimeInterval.milliseconds(1000)) { self.persistentInteractor = persistentInteractor self.computerThinkingTime = computerThinkingTime } } let thunkMiddleware: Middleware<AppState> = createThunkMiddleware() public struct Thunk<State>: Action { let body: (_ dispatch: @escaping DispatchFunction, _ getState: @escaping () -> State?, _ dependency: Dependency) -> Void init(body: @escaping ( _ dispatch: @escaping DispatchFunction, _ getState: @escaping () -> State?, _ dependency: Dependency) -> Void) { self.body = body } } func createThunkMiddleware<State>(dependency: Dependency = DependencyImpl()) -> Middleware<State> { return { dispatch, getState in return { next in return { action in switch action { case let thunk as Thunk<State>: thunk.body(dispatch, getState, dependency) default: next(action) } } } } } let loggingMiddleware: Middleware<AppState> = { dispatch, getState in return { next in return { action in dump(action) if case AppPrivateAction.validateTurn = action { print(getState()?.boardContainer.board.debugDescription ?? "N/A") } return next(action) } } }PersistentInteractor & Repository
ゲーム状態をファイルに保存・読み込みするためのロジックです。
既存では独自のファイルファーマット形式で保存していましたが、ReduxのStateをCodableに準拠してJSON形式でStateを丸ごと保存するようにしました。メリット
- 独自のファイルファーマット形式のパースロジックを排除し、一般的でかつパースAPIが提供されているJSONを利用できた
デメリット
- Stateの構造を変更すると、下位互換がなくなってしまいデータをロードできなくなってしまった
結論
- 過剰なリファクタリングでした
protocol PersistentInteractor { func saveGame(_ appState: AppState) throws /* PersistentError */ func loadGame() throws -> AppState /* PersistentError */ } struct PersistentInteractorImpl: PersistentInteractor { enum PersistentError: Error { case write(cause: Error?) case read(cause: Error?) } private let repository: Repository init(repository: Repository = RepositoryImpl()) { self.repository = repository } func encode(_ appState: AppState) throws -> Data { let encoder = JSONEncoder() encoder.outputFormatting = .prettyPrinted return try encoder.encode(appState) } func saveGame(_ appState: AppState) throws { do { let data = try encode(appState) try repository.saveData(data) } catch let error { throw PersistentError.read(cause: error) } } func loadGame() throws -> AppState { do { let data = try repository.loadData() return try JSONDecoder().decode(AppState.self, from: data) } catch let error { throw PersistentError.write(cause: error) } } } extension Coordinate { /* Codable */ enum CodingKeys: String, CodingKey { case x case y } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(x, forKey: .x) try container.encode(y, forKey: .y) } }ファイルに保存するときのファイル名はコンストラクタインジェクションできるようにし、テスト時は変更できるようにしました。
アプリ実行時とテスト実行時で保存したファイルを競合しないようにするためです。protocol Repository { func saveData(_ data: Data) throws /* FileIOError */ func loadData() throws -> Data /* FileIOError */ func clear() throws /* FileIOError */ } struct RepositoryImpl: Repository { enum FileIOError: Error { case write(cause: Error?) case read(cause: Error?) case clear(cause: Error?) } let fileName: String init(fileName: String = "appstate.json") { self.fileName = fileName } private func createFileURL() throws -> URL { try FileManager.default .url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) .appendingPathComponent(fileName) } func saveData(_ data: Data) throws { do { let fileURL = try createFileURL() try data.write(to: fileURL, options: []) } catch let error { throw FileIOError.read(cause: error) } } func loadData() throws -> Data { do { let fileURL = try createFileURL() return try Data(contentsOf: fileURL) } catch let error { throw FileIOError.write(cause: error) } } func clear() throws { do { let fileURL = try createFileURL() try FileManager.default.removeItem(at: fileURL) } catch let error { throw FileIOError.clear(cause: error) } } }ViewController
ViewControllerのすべてのコードはGitHub repositoryで確認できます。
ViewControllerのイニシャライザを利用してReduxのStreをコンストラクタインジェクションしたかっったので、storyboardからxibに変更しました。
class ViewController: UIViewController, StoreSubscriber { ... private let store: Store<AppState> init(store: Store<AppState> = Logic.store) { self.store = store super.init(nibName: nil, bundle: nil) }ViewControllerのコードは3種類の役割に分割できます。
- #1 / State handling(State -> Game management or View update)
- #2 / Game management(Game management -> Dispatch Action)
- #3 / View update
#1 / State handling(State -> Game management or View update)
Reduxから変更があった場合に通知を受、新たなStateの状態に基づいて、Game managementを行ったたり、Viewの表示を更新する指示を出しています。
func newState(state: AppState)
はどんな変更であろうと通知され、表示を変更する処理を記載subscriberGameProgress
はGameProgress
に変更があった場合のみ通知され、GameProgress
の状況をパターンマッチのうえ、各状況における適切な処理を記載subscriberBoardContainer
はBoardContainer
に変更があった場合のみ通知され、盤面の変更があった場合に盤面の表示を変更する処理を記載class ViewController: UIViewController, StoreSubscriber { ... override func viewDidLoad() { super.viewDidLoad() boardView.delegate = self boardView.setUp(boardSetting: store.state.boardContainer.boardSetting) messageDiskSize = messageDiskSizeConstraint.constant store.subscribe(self) store.subscribe(subscriberGameProgress) { appState in appState.select { $0.gameProgress }.skipRepeats() } store.subscribe(subscriberBoardContainer) { appState in appState.select { $0.boardContainer }.skipRepeats() } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) loadGame() } func newState(state: AppState) { updatePlayerControls(state.gameProgress, playerSide: state.playerDark) updatePlayerControls(state.gameProgress, playerSide: state.playerLight) updateCountLabels(state.playerDark) updateCountLabels(state.playerLight) updateMessageViews(state.gameProgress) } private lazy var subscriberGameProgress = BlockSubscriber<GameProgress>() { [unowned self] in switch $0 { case .initialing: self.animationState.cancelAll() self.startGame() case .turn(let progress, let side, _, let computerThinking): self.updatePlayerActivityIndicators(side: side, computerThinking: computerThinking) switch progress { case .start: self.waitForPlayer() case .progressing: break } case .gameOver: break case .interrupt(let interrupt): switch interrupt { case .cannotPlaceDisk(let alert): switch alert { case .shouldShow: self.showCannotPlaceDiskAlert() case .none, .showing: break } case .resetConfirmation(let alert): switch alert { case .shouldShow: self.showRestConfirmationAlert() case .none, .showing: break } } } } private lazy var subscriberBoardContainer = BlockSubscriber<BoardContainer>() { [unowned self] in switch $0.changed { case .none: self.updateDisksForInitial($0.diskCoordinates) case .some(let changed): self.updateDisks(changed: changed, animated: true) { [weak self] _ in self?.nextTurn() } } } }Additional for ReSwift's subscriber
class BlockSubscriber<S>: StoreSubscriber { typealias StoreSubscriberStateType = S private let block: (S) -> Void init(_ block: @escaping (S) -> Void) { self.block = block } func newState(state: S) { self.block(state) } }#2 / Game management(Views -> State)
Game managementに関するメソッドが並びます。
View側からReduxのStateを変更するためのActionをdispatchする処理になります。extension ViewController { func saveGame() { store.dispatch(AppAction.saveGame()) } func loadGame() { store.dispatch(AppAction.loadGame()) } func newGame() { animationState.cancelAll() store.dispatch(AppAction.newGame()) } func startGame() { store.dispatch(AppAction.startGame) } func nextTurn() { store.dispatch(AppAction.nextTurn()) } func waitForPlayer() { store.dispatch(AppAction.waitForPlayer()) } func placeDisk(_ placedDiskCoordinate: PlacedDiskCoordinate) { store.dispatch(AppAction.placeDisk(placedDiskCoordinate)) } func changePlayer(side: Side, player: Player) { store.dispatch(AppAction.changePlayer(side: side, player: player)) animationState.cancel(at: side) } func cannotPlaceDisk(alert: Alert) { store.dispatch(AppAction.cannotPlaceDisk(alert)) } func resetConfirmation(alert: Alert) { store.dispatch(AppAction.resetConfirmation(alert)) } }#3 / View update(State -> Views)
Viewの表示を更新するためのメソッドが並びます。
extension ViewController { func updateMessageViews(_ gameProgress: GameProgress) { switch gameProgress { case .initialing, .interrupt: break case .turn(_, let side, _, _): messageDiskSizeConstraint.constant = messageDiskSize messageDiskView.disk = side.disk messageLabel.text = "'s turn" case .gameOver(let gameOver): switch gameOver { case .won(let winner): messageDiskSizeConstraint.constant = messageDiskSize messageDiskView.disk = winner.disk messageLabel.text = " won" case .tied: messageDiskSizeConstraint.constant = 0 messageLabel.text = "Tied" } } } func updateDisksForInitial(_ diskCoordinates: [OptionalDiskCoordinate]) { diskCoordinates.forEach { boardView.updateDisk($0.disk, coordinate: $0.coordinate, animated: false) } } func updateDisks(changed: BoardChanged, animated isAnimated: Bool, completion: ((Bool) -> Void)? = nil) { let disk = changed.placedDiskCoordinate.disk let placedCoordinate = changed.placedDiskCoordinate.coordinate let flippedCoordinates = changed.flippedDiskCoordinates.map { $0.coordinate } if isAnimated { animationState.createAnimationCanceller() updateDisksWithAnimation(at: [placedCoordinate] + flippedCoordinates, to: disk) { [weak self] finished in guard let self = self else { return } if self.animationState.isCancelled { return } self.animationState.cancel() completion?(finished) self.saveGame() } } else { DispatchQueue.main.async { [weak self] in guard let self = self else { return } self.boardView.updateDisk(disk, coordinate: placedCoordinate, animated: false) flippedCoordinates.forEach { self.boardView.updateDisk(disk, coordinate: $0, animated: false) } completion?(true) self.saveGame() } } } private func updateDisksWithAnimation<C: Collection>(at coordinates: C, to disk: Disk, completion: @escaping (Bool) -> Void) where C.Element == Coordinate { guard let coordinate = coordinates.first else { completion(true) return } boardView.updateDisk(disk, coordinate: coordinate, animated: true) { [weak self] finished in guard let self = self else { return } if self.animationState.isCancelled { return } if finished { self.updateDisksWithAnimation(at: coordinates.dropFirst(), to: disk, completion: completion) } else { coordinates.forEach { self.boardView.updateDisk(disk, coordinate: $0, animated: false) } completion(false) } } } private func updatePlayerActivityIndicators(side: Side, computerThinking: ComputerThinking) { switch computerThinking { case .thinking: self.playerActivityIndicators[side.index].startAnimating() case .none: self.playerActivityIndicators.forEach { $0.stopAnimating() } } } func updatePlayerControls(_ gameProgress: GameProgress, playerSide: PlayerSide) { playerControls[playerSide.side.index].selectedSegmentIndex = playerSide.player.rawValue playerControls.forEach { switch gameProgress { case .turn: $0.isEnabled = true case .initialing, .interrupt, .gameOver: $0.isEnabled = false } } } func updateCountLabels(_ playerSide: PlayerSide) { countLabels[playerSide.side.index].text = "\(playerSide.count)" } func showCannotPlaceDiskAlert() { cannotPlaceDisk(alert: .showing) let alertController = UIAlertController( title: "Pass", message: "Cannot place a disk.", preferredStyle: .alert ) alertController.addAction(UIAlertAction(title: "Dismiss", style: .default) { [weak self] _ in self?.cannotPlaceDisk(alert: .none) self?.nextTurn() }) present(alertController, animated: true) } func showRestConfirmationAlert() { resetConfirmation(alert: .showing) let alertController = UIAlertController( title: "Confirmation", message: "Do you really want to reset the game?", preferredStyle: .alert ) alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in self?.resetConfirmation(alert: .none) self?.waitForPlayer() }) alertController.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] _ in self?.newGame() }) present(alertController, animated: true) } }User inputs
extension ViewController { @IBAction func pressResetButton(_ sender: UIButton) { resetConfirmation(alert: .shouldShow) } @IBAction func changePlayerControlSegment(_ sender: UISegmentedControl) { guard let index = playerControls.firstIndex(of: sender) else { return } let side: Side switch index { case 0: side = .sideDark case 1: side = .sideLight default: preconditionFailure() } changePlayer(side: side, player: sender.convertToPlayer) } } extension ViewController: BoardViewDelegate { func boardView(_ boardView: BoardView, didSelectCellAt coordinate: Coordinate) { guard !animationState.isAnimating else { return } guard case .turn(_, let side, let player, _) = store.state.gameProgress else { return } guard case .manual = player else { return } placeDisk(PlacedDiskCoordinate(disk: side.disk, coordinate: coordinate)) } }ロギング
リファクタリングの手がかりとして状態の変化を都度ログに出力しました。
- Action, ActionCreatorがdispatchされたときはパラメータも合わせて出力
- 次のターンになると盤面のデータ状態を出力
- Logic.AppAction.changePlayer(side: sideLight, player: computer) START ▿ Logic.AppPrivateAction.changePlayer ▿ changePlayer: (2 elements) - side: Logic.Side.sideLight - player: Logic.Player.computer - Logic.AppAction.waitForPlayer() START - Logic.AppAction.playTurnOfComputer() START - Logic.AppPrivateAction.startComputerThinking - Logic.AppAction.playTurnOfComputer() END - Logic.AppAction.waitForPlayer() END - Logic.AppAction.saveGame() START - Logic.AppPrivateAction.finisedSaveGame - Logic.AppAction.saveGame() END - Logic.AppAction.changePlayer(side: sideLight, player: computer) END - Logic.AppAction.nextTurn() from: sideDark to: sideLight - Logic.AppPrivateAction.nextTurn - Logic.AppPrivateAction.validateTurn @01234567 0-------- 1-------- 2---x---- 3---xx--- 4---xo--- 5-------- 6-------- 7-------- - Logic.AppAction.waitForPlayer() START - Logic.AppAction.playTurnOfComputer() START - Logic.AppPrivateAction.startComputerThinking - Logic.AppAction.playTurnOfComputer() END - Logic.AppAction.waitForPlayer() END - Logic.AppAction.saveGame() START - Logic.AppPrivateAction.finisedSaveGame - Logic.AppAction.saveGame() END - Logic.AppPrivateAction.endComputerThinking ▿ Logic.AppAction.placeDisk ▿ placeDisk: Logic.PlacedDiskCoordinate - disk: Logic.Disk.diskLight ▿ coordinate: Logic.Coordinate - x: 2 - y: 2 - Logic.AppAction.nextTurn() from: sideLight to: sideDark - Logic.AppPrivateAction.nextTurn - Logic.AppPrivateAction.validateTurn @01234567 0-------- 1-------- 2--ox---- 3---ox--- 4---xo--- 5-------- 6-------- 7-------- - Logic.AppAction.waitForPlayer() START - Logic.AppAction.playTurnOfComputer() START - Logic.AppPrivateAction.startComputerThinking - Logic.AppAction.playTurnOfComputer() END - Logic.AppAction.waitForPlayer() END - Logic.AppAction.saveGame() START - Logic.AppPrivateAction.finisedSaveGame - Logic.AppAction.saveGame() END - Logic.AppPrivateAction.endComputerThinking ▿ Logic.AppAction.placeDisk ▿ placeDisk: Logic.PlacedDiskCoordinate - disk: Logic.Disk.diskDark ▿ coordinate: Logic.Coordinate - x: 5 - y: 4CI/TEST
GitHub ActionでXcodeのビルドとテストを実施できるようにしました。
# .github/workflows/build.yml on: push: branches: - 'master' pull_request: branches: - '**' env: project_nmae: Reversi scheme: Reversi configuration: Debug name: Xcode build jobs: validate: name: Validate runs-on: macOS-latest strategy: matrix: destination: - "platform=iOS Simulator,OS=13.4.1,name=iPhone 11 Pro" steps: - name: Checkout uses: actions/checkout@master - name: Switch to workspace directory run: cd $GITHUB_WORKSPACE - name: Install tooling run: sudo xcode-select -s /Applications/Xcode_11.4.1.app - name: Resolve swift package dependencies run: xcodebuild -resolvePackageDependencies -scheme '${{ env.scheme }}' -clonedSourcePackagesDirPath ./.swiftpackages -derivedDataPath ./.build - name: Run tests ${{ matrix.destination }} run: xcodebuild -sdk iphonesimulator -scheme '${{ env.scheme }}' -configuration '${{ env.configuration }}' -destination '${{ matrix.destination }}' -clonedSourcePackagesDirPath ./.swiftpackages -derivedDataPath ./.build clean test | xcpretty既知の不具合への対処
本チャレンジではリファクタリング対応にとどまらず、潜在する不具合を発見し的確に対処する必要があります。
手詰まりしたときの要対応事象 その1
手詰まりになった場合、アラートが表示された状態でアプリを終了すると、再度アプリを起動して前回終了時をロードしたときにクラッシュします。
手詰まりしたときの要対応事象 その2
以下のように、ひとまずクラッシュしないように改修すると、今度は再度アプリを起動して前回終了時をロードしたときに手詰まりのアラートが表示されず当該ターンではディスクを置けない状態に陥ります。
diff --git a/Reversi/ViewController.swift b/Reversi/ViewController.swift index 321824a..411c06b 100644 --- a/Reversi/ViewController.swift +++ b/Reversi/ViewController.swift @@ -285,6 +285,7 @@ extension ViewController { /// "Computer" が選択されている場合のプレイヤーの行動を決定します。 func playTurnOfComputer() { guard let turn = self.turn else { preconditionFailure() } + guard !validMoves(for: turn).isEmpty else { return } let (x, y) = validMoves(for: turn).randomElement()!リセットアラートの表示時の要対応事象
リセットボタンを押下し、リセットの確認アラートを表示している状態でもコンピュータの操作は可能なのでゲームは進行していきます。このとき、手詰まりが発生すると、手詰まりのアラートを表示すべきところですが、すでにリセットの確認アラートを表示しているためリセットの確認アラートを表示できず操作ができない状態に陥ります。
リグレッションしないように気を付けるところ
- 1. コンピュータが思考中にリセットした場合、リセット後のゲームでコンピュータの思考中が継続され誤ってディスクを指さないこと
- 2. フリップアニメーション中にリセットした場合、リセット後のゲームでフリップアニメーションが継続されないこと
1. 2. まとめ
反省点
- Reduxアーキテクチャに移行にあたって、リファクタリングがおおむね完了するまでテストコードを導入できなかった
- 移行が完了しないとReduxを活かしたテストコードを導入できなかったため
- テストコードでリファクタリングの過程にリグレッションしないことを担保したかった
- リファクタリング前と後でコードステップ数を計測すると67%増加していた
- Reduxのメリットの堅牢を求めるあまり、複雑なコードになったり過剰なリファクタリングとなった部分が否めない
コードステップ数
- リファクタリング前 リファクタリング後 ステップ数 775 1,295 未完成部分
今後の課題です。
- テストコードの記述がほとんどありません、もっとテストを書くべき!
- システム的な異常系のハンドリングは
preconditionFailure
とassertionFailure
の活用にとどまっており、ユーザーに異常状態を通知できていない感想
リバーシという題材のチョイスがよく程よいボリューム感とリバーシロジックの難易度があり、ベースコードのファトコードの再現具合も絶妙で多様なアーキテクチャでリファクタリングのアプローチが可能なうえ、噛めば噛むほど味の出るすばらしいチャレンジだと感じました。
- 投稿日:2020-05-16T18:23:58+09:00
5 月 LeetCode 挑戰, W3D2, Odd Even Linked List (Linked List), in Swift
資料結構與演算法:
Linked List
- 挑戰頁面 - Odd Even Linked List
- 原題目頁面 - 328. Odd Even Linked List
題意
- 給一個單向鏈結
- 把這個單向鏈結分第奇數個及第偶數個兩堆,然後以把偶的那堆放在奇後面
例如
1 -> 3 -> 5 -> 7第奇數個有 1 和 5 ,第偶數個有 3 和 7
因此結果是
1 -> 5 -> 3 -> 7題目要求的條件是:時間複雜度 O(n), 空間複雜度 O(1)
思考方式
- 宣告兩個鏈結,分別裝奇數項節點和偶數項節點
- 走訪完之後,把偶數項的鏈結接到奇數項鏈結的後面即可
注意點:
- 偶數的陣列需要把最後一個節點的 .next 設成 nil 。因為如果原先後面有接一個奇數節點的話,我們必須把它斷開。
- 因為奇數鏈結的最後一個節點會接上偶數鏈結的第一個節點,因此不用特別做斷開的處理
Linked List (鏈結)的操作技巧
- 建立 dummy node 作為 head node
- 為了保留第一個位置的資訊,就像港口的繫纜柱一樣的作用把繩子定住。雖然和繩子繫在一起,但是他在資料上並沒有任何意義。
- 回傳的時候回傳 head.next , head.next 才是這個鏈結的真正起點
- 保留
最後一個位置
的節點資訊方便街上下一個節點程式碼
class Solution { func oddEvenList(_ head: ListNode?) -> ListNode? { var oddHead: ListNode? = ListNode() var oddCurrent: ListNode? = oddHead var evenHead: ListNode? = ListNode() var evenCurrent: ListNode? = evenHead /// 用來走訪傳入的鏈結 var current: ListNode? = head /// 用來判斷現在是奇還是偶 var isOdd = true while current != nil { if isOdd { oddCurrent?.next = current oddCurrent = oddCurrent?.next } else { evenCurrent?.next = current evenCurrent = evenCurrent?.next } isOdd.toggle() current = current?.next } oddCurrent?.next = evenHead?.next evenCurrent?.next = nil return oddHead?.next } }複雜度分析
n 為總節點數
- 時間複雜度: O(n)
- 只有走訪過一次
- 空間複雜度: O(1)
- 標誌奇數偶數項的變數是常數個
- dummy heads 也是常數個
執行結果
Runtime: 28 ms (beats 94.52%) Memory Usage: 22 MB
- 投稿日:2020-05-16T18:01:20+09:00
SwiftUIで「式が複雑すぎる」「式を分解しろ」と言われたら
解決できるかもしれないエラー
The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions
このエラーによく遭遇するのですが、具体的な解決法が分からず毎度苦戦します。fizzbuzzのような2つの条件を扱おうとするだけでも出現するエラーです。とても厄介。ここでは一つの解決策を共有できればと思います。参考
エラー例
import SwiftUI struct test: View { var body: some View { List(0..<100) { number in if number % 3 == 0 || number % 5 == 0 { Text("number\(number) は3または5の倍数") } else { Text("number\(number)3と5の倍数のいずれでもない") } } } } struct escape_Previews: PreviewProvider { static var previews: some View { test() } }
if number % 3 == 0 || number % 5 == 0
に問題があるようです。複雑というよりも、処理が一度に複数あるイメージでしょうか。
解決するために,if number % 3 == 0 || number % 5 == 0
を関数にします。import SwiftUI struct escape: View { func check(number: Int) -> Bool { number % 3 == 0 || number % 5 == 0 } var body: some View { List(0..<100) { number in if self.check(number: number){ Text("number\(number) は3または5の倍数") } else { Text("number\(number)3と5の倍数のいずれでもない") } } } } struct escape_Previews: PreviewProvider { static var previews: some View { escape() } }
- 投稿日:2020-05-16T16:58:40+09:00
データを他のアプリへ送る(Swift5)
概要
めちゃくちゃお久しぶりです。書こう書こうと思ってるとすぐ時間が過ぎる・・・
さて、SNSへ投稿したりするあれです。
SNSだけじゃなくて対応したアプリ(自作含め)は表示されますので好きなアプリに送信すれば良いわけです。
今回はサンプル画像をメモに送って確認したいと思います。
UIDocumentInteractionControllerを使います。
これを使ったサンプル記事はQiita内外でもあるのでご参照ください。ところでこれ、ハマりました。
罠がありますがあんまり触れている方が少なかったので記事にしました。
画像(データ)が送れなかったり、コントローラが表示されなかったりしました。まず、論よりコードです。動くやつ。
storyboardにてボタンの追加とイベント処理を追加してますがそれは省略します。サンプルコード
ViewController.swift// // Copyright (c) 2020年, hats_yaki. All rights reserved. // // import UIKit class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. } // 追加したコード @IBAction func touch(_ sender: Any) { let img = UIImage(named: "cat4")! // cat4.pngという名前でasset登録済とする _ = SendDocumentView(image: img, targetView: self.view) } }SendDocumentView.swift// // Copyright (c) 2020年, hats_yaki. All rights reserved. // import UIKit class SendDocumentView :NSObject { var dController :UIDocumentInteractionController! var reference :NSObject! init(image:UIImage, targetView:UIView) { super.init() self.reference = self // 自身への参照 guard let data = image.pngData() else { return } // ファイル一時保存してNSURLを取得 let url = NSURL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("tmp.png")! do{ try data.write(to: url, options: Data.WritingOptions.atomicWrite) }catch{ print("画像データ保存でエラー") return } dController = UIDocumentInteractionController.init(url: url) if !(dController.presentOpenInMenu(from: CGRect(x: 0, y: 0, width: 500, height: 300), in: targetView, animated: true)) { print("ファイルに対応するアプリがない") } } }ちょっとだけ解説
データ送信機能はSendDocumentView(Viewじゃないんですが)として独立させました。
これにUIImageと親となるUIViewを渡すと送信画面が表示されます。
疎結合なのでユーティリティのように使えるのではないでしょうか。dController.presentOpenInMenuのところはもっと汎用的に書いている他の人のコードを参考にしてください。私はまだよくわかっていません。
あとdControllerの消滅とともにリソースが破棄されることを期待していますがそれもちゃんと動くかは要確認です。ハマったとこ
さて、ハマったとこですがSendDocumentViewのinitの2行目に自身への参照を持たせています。
一見これに意味がなさそうですがこれがないと期待する挙動になりませんでした。
iOS12系のiPhoneではコントローラーは表示されるのですが画像が空になり、iOS13系のiPadだとそもそもコントローラーが表示されなかったりしました。
なんで違いが・・・違いが出る理由がわかりませんが参照関係にある(あるべき)オブジェクトの生存期間の違いによるものだと思われます。詳しい方教えてもらえたら嬉しいです・・・
- 投稿日:2020-05-16T16:43:01+09:00
ストーリボードでダークモード対応のカラーとイメージを使ってアプリを作る方法
はじめに
最近プロジェクトでStroyboardを使う機会ができ、Stroyboardでカラーやイメージにダークモード対応する方法を調べたので、メモとして記事を書いてみました
今回の記事はStoryboardのみなので、コードは全くないです今までコードでUIを作ってきたので、iOSエンジニア2年目でStoryboardの便利さに気づきましたw
※Xcodeのバージョンは11.2.1です
ダークモードの設定
Storyboardでダークモード対応するには、事前にAssetsで設定する必要があります
カラーとイメージそれぞれの設定からしましょう
※今回はPDFファイルのイメージを使ってますカラー
まずは右クリクで「New Color Set」を選択して、Assetsに新しいColor Setを用意する
※Color Setの名前は好きな名前にする(サンプルではTextColorにしいてる)
Utility AreaのAppearancesを「Any, Light, Dark」に変更してDark Modeの色を設定できるようにする
写真のようにColor Setの左2つに普段のカラーを、右のDark AppearanceにDark Modeのカラーを設定する
イメージ
まずは右クリクで「New Image Set」を選択して、Assetsに新しいImage Setを用意する
※Image Setの名前は好きな名前にする(サンプルではbackgroundにしている)
PDFファイルの画像を1枚挿入して、Utility AreaのPreserve Vector Dataにチェックをいれる
※チェックをいれるのはPDFファイルの画像を利用しているため
Utility AreaのScalesを「Single Scale」に変更する
※PDFファイルの場合は画像を1枚しか利用しないため
Utility AreaのAppearancesを「Any, Light, Dark」に変更してDark Modeのイメージを設定できるようにする
写真のようにImage Setの上2つに普段のイメージを、下のDark AppearanceにDark Modeのイメージを設定する
ダークモードのアプリを作る
Main.storyboardでUIImageViewとUILabelを追加してレイアウトを設定した前提で次にすすむ
StoryboardのUIにダークモード対応のカラーとイメージを設定する
UIImageViewのImageにダークモード対応のイメージを設定する
Utility AreaのImageViewのImageで設定したImage Setを選択する(サンプルの場合はbackground)
UIlabeの文字色にダークモード対応のカラーを設定する
Utility AreaのLabelのColorで設定したColor Setを選択する(サンプルの場合はTextColor)
完成アプリ
- 投稿日:2020-05-16T16:29:51+09:00
Swift学習記#4「関数の定義」
はじめに
今回はswiftにおける関数の定義方法について書いて行きます!
関数は初めて触ると、どの様にデータが流れているのか
よくわかりませんよね!僕も最初はそうでした!なのでここでは、基礎的な定義とともに
引数や戻り値なんかについても覚えて、
データの流れをしっかり掴みましょう!関数とは
関数(function)とは、
簡潔に言うと「特定の処理を行うためのまとまり」です。他の言語でも用いられていることが多いので、
他の言語を学習したことのある方はすでにご存知かと思います。関数の定義
書式はこんな感じ、、、
test.Playgroundfunc 関数名(引数名1:引数1の型, 引数名2:引数2の型, ...)-> 戻り値の型{ return //戻り値 }定義の中にある引数というのは、「関数に渡す値」のことで
渡す値は、その後に書くコードの中で指定します。具体例はこんな感じ、、、
test.Playgroundfunc test(test1:Int, test2:Int)-> Int{ // 関数で行う処理 var num = 100 return num + test1 - test2 //戻り値 } // 関数の呼び出し print(test(test1:10, test2:100)) // 引数最後の「print(test(test1:10, test2:100))」で
関数testに「test1:10, test2:100」という値を渡し、関数で処理された
データが戻ってきて(これを戻り値という)表出されている。可変個引数
一つの引数で複数の値を渡す場合。
具体例はこんな感じ、、、
test.Playgroundfunc test ( num: Int... ){ //.enumerated()は列挙型 for (i, num) in num.enumerated() { print(num) print( "\(i) - \(num)" ) } } test( num:5, 6, 7 ) // 結果 ー> 5 0 - 5 6 1 - 6 7 2 - 7こうすることで、引数で複数の値を渡すことができる。
引数の外部名
swiftでは、引数の関数の外での名前を決めることができる。
具体例はこんな感じ、、、
test.Playground// 標準形 func getArea( radius r : Double ) -> Double { let pi = 3.14 return pi * r * r } var a = getArea( radius: 3 ) print( a ) // 省略形 func getArea( _ r : Double ) -> Double { let pi = 3.14 return pi * r * r } var a = getArea( 3 ) print( a )
( radius r : Double )
の様に、
(外の引数名 中の引数名:データの型)
の形を作ることができる。また、関数の外での引数名は、
( _ r : Double )
の様に
アンダースコアを記入することで省略できる。タプルを返す関数
swiftでは、関数は戻り値をタプルで返すこともできる。
*タプルとは、
複数の変数をまとめ上げたり、
ひとつの変数に簡単にわかりやすい意味を与えたりするもので、
()で括って指定する。例:(name:String, age:Int)具体例はこんな感じ、、、
test.Playgroundfunc group(ID:Int) -> (name:String, age:Int) { //戻り値がタプル switch ID { case 1000: return ( "Ichiro", 30 ) default: return ( "Unknown", 0 ) } } var p1 = group( ID: 1000 ) print( "\(p1.name) - \(p1.age)" ) var p2 = group( ID: 5 ) print( "\(p2.name) - \(p2.age)" ) // 結果 ー> Ichiro - 30 Unknown - 0inoutによる参照渡し
swiftでは、関数を定義する際に
( msg : inout String )
の様に
データ型の前にinout
をつけ、引数を設定する際に( msg: &s )
の様に
引数の値の前に&
をつけると参照渡すを行うことができる。*参照渡しとは
通常の引数は、関数外の変数の値をコピーして渡す値渡しを行うため、
関数外の変数の値に変化はない。
しかし参照渡しの場合は、関数ないで変数の値を変更すると、
その変更を関数外にある元の変数と共有することになる。具体例はこんな感じ、、、
test.Playgroundfunc myfunc ( msg : inout String ) { msg = "Hello World!" } var s = "Hello!" print( "s = \(s)" ) myfunc( msg: &s ) print( "s = \(s)" ) print(s) // 結果 ー> s = Hello! s = Hello World! Hello World! // 変数が関数での変更を共有しているこれまで
今回は関数の定義について書いて行きました。
swift初級で使用するのは、この位ではないかと思います。
学習が進めばさらにいろんな表記や手法が出てくると思うので、
その都度追加していこうと思います!ここまでで、swift学習記も#4になり、
基礎知識もあらかたついた様な気もするので、
これからはアプリを開発しながらの学習をやって行きます!
- 投稿日:2020-05-16T15:29:56+09:00
TwitterのプロフィールであるようなUITableViewのヘッダーの画像を引っ張って拡大させる機能をたった数行のコードで実装する
Tiwtter等のアプリでよくあるプロフィール画面のカバー画像を下に引っ張ると拡大されるPrallaxHeader機能(っていうんですかね?)ですが、
実はこれめちゃくちゃ簡単に実装できます。GitHubにサンプルもあります。
環境
Xcode Version 11.4.1 (11E503a)
Swift version 5.2.2 (swiftlang-1103.0.32.6 clang-1103.0.32.51).実装
使用しているはUITableViewとtableHeaderViewです。
まあこの構成はよくあると思いますが、ポイントはヘッダー画像をtableHeaderViewに入れないことです。DebugViewHierarchyでみるとよくわかると思います。
Interface Builder
- UIViewController.View
- UIImageView ←これがTwitterでいう下に引っ張ると拡大されるカバー画像になる。
- UITableView ←透明にする。
- UIView ←これがtableHeaderView
- UIView ←これがカバーイメージの上にかぶる部分。透明にする。
- UIView ←ヘッダーに載せたいコンテンツを載せるView。背景色をつける。
- UILabel ←ヘッダーに載せたいコンテンツ。今回は適当にラベル。
上記の構成でViewを配置し、カバー画像のUIImageView下記のAutoLayoutをかけます。
- Align Top to SuperView Top
- Align Center X
- 任意のAspect Ratio
- Height Equal = 適当な数値カバーイメージの上にかぶる部分の透明なViewに下記のAutoLayoutをかけます。
- Align Top to SuperView Top
- Trailing Space To SuperView
- Leading Space To SuperView
- カバー画像と同じAspect Ratioあとは実装したいUIにあわせます。
コード
カバー画像Viewの
Align Top to SuperView Top
と- Height Equal = 適当な数値
をIBOutletで接続します。@IBOutlet private var coverImageHeightConstraint: NSLayoutConstraint! @IBOutlet private var coverImageTopConstraint: NSLayoutConstraint!
- Height Equal = 適当な数値
のままだと高さがずれるのでデフォルトの高さをセットしてあげてください。private var defaultCoverImageHeight: CGFloat { return self.view.frame.width / 3 } override func viewDidLoad() { super.viewDidLoad() self.coverImageHeightConstraint.constant = self.defaultCoverImageHeight }最後に、
UITableViewDelegate.scrollViewDidScroll(_ scrollView: UIScrollView)
の中にcontentsOffset.yの変化に応じて適当な値をIBOutletで接続した制約に与えてあげれば終わりです。extension ViewController: UITableViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { self.coverImageHeightConstraint.constant = max(self.defaultCoverImageHeight, -(scrollView.contentOffset.y - self.defaultCoverImageHeight)) self.coverImageTopConstraint.constant = min(0, -scrollView.contentOffset.y) } }おわり
UITableViewの下にUIImageViewを配置し、必要な箇所の背景色を透明にして制約をいじるだけなので割と既存の実装に反映させやすいと思います。
また、スクロール位置に合わせてブラーを追加等も機能も簡単に追加できると思うのでおすすめです。
- 投稿日:2020-05-16T15:29:56+09:00
SwiftでTwitterのプロフィールであるようなUITableViewのヘッダーの画像を引っ張って拡大させる機能をたった数行のコードで実装する
Tiwtter等のアプリでよくあるプロフィール画面のカバー画像を下に引っ張ると拡大されるPrallaxHeader機能(っていうんですかね?)ですが、
実はこれめちゃくちゃ簡単に実装できます。GitHubにサンプルもあります。
環境
Xcode Version 11.4.1 (11E503a)
Swift version 5.2.2 (swiftlang-1103.0.32.6 clang-1103.0.32.51).実装
使用しているはUITableViewとtableHeaderViewです。
まあこの構成はよくあると思いますが、ポイントはヘッダー画像をtableHeaderViewに入れないことです。DebugViewHierarchyでみるとよくわかると思います。
Interface Builder
- UIViewController.View
- UIImageView ←これがTwitterでいう下に引っ張ると拡大されるカバー画像になる。
- UITableView ←透明にする。
- UIView ←これがtableHeaderView
- UIView ←これがカバーイメージの上にかぶる部分。透明にする。
- UIView ←ヘッダーに載せたいコンテンツを載せるView。背景色をつける。
- UILabel ←ヘッダーに載せたいコンテンツ。今回は適当にラベル。
上記の構成でViewを配置し、カバー画像のUIImageView下記のAutoLayoutをかけます。
- Align Top to SuperView Top
- Align Center X
- 任意のAspect Ratio
- Height Equal = 適当な数値カバーイメージの上にかぶる部分の透明なViewに下記のAutoLayoutをかけます。
- Align Top to SuperView Top
- Trailing Space To SuperView
- Leading Space To SuperView
- カバー画像と同じAspect Ratioあとは実装したいUIにあわせます。
コード
カバー画像Viewの
Align Top to SuperView Top
と- Height Equal = 適当な数値
をIBOutletで接続します。@IBOutlet private var coverImageHeightConstraint: NSLayoutConstraint! @IBOutlet private var coverImageTopConstraint: NSLayoutConstraint!
- Height Equal = 適当な数値
のままだと高さがずれるのでデフォルトの高さをセットしてあげてください。private var defaultCoverImageHeight: CGFloat { return self.view.frame.width / 3 } override func viewDidLoad() { super.viewDidLoad() self.coverImageHeightConstraint.constant = self.defaultCoverImageHeight }最後に、
UITableViewDelegate.scrollViewDidScroll(_ scrollView: UIScrollView)
の中にcontentsOffset.yの変化に応じて適当な値をIBOutletで接続した制約に与えてあげれば終わりです。extension ViewController: UITableViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { self.coverImageHeightConstraint.constant = max(self.defaultCoverImageHeight, -(scrollView.contentOffset.y - self.defaultCoverImageHeight)) self.coverImageTopConstraint.constant = min(0, -scrollView.contentOffset.y) } }おわり
UITableViewの下にUIImageViewを配置し、必要な箇所の背景色を透明にして制約をいじるだけなので割と既存の実装に反映させやすいと思います。
また、スクロール位置に合わせてブラーを追加する機能等も簡単に追加できると思うのでおすすめです。
- 投稿日:2020-05-16T12:16:35+09:00
Swift学習記#3「条件分岐式について」
はじめに
今回は、前回の繰り返し文と同じく開発の中で頻繁に用いられる
条件分岐についてです。その中でも、僕が「特に使うのではないか」と思う
if
、switch ~ case
、guard
の3つについて解説して行きます!if ( if ~ else, if ~ else if )
書式はこんな感じ、、、
test.Playgroundif 条件式 { // 正の場合の処理 } else { // 負の場合の処理 }
if
文の条件分岐式は、対象になる変数(または定数)が
設定された条件に対して正(正しい)か、負(間違っている)か
によって行う処理が変わるというもの。実際には、
test.Playgroundvar num = 12 if num % 2 = 0 { // 正の場合の処理 print("numは偶数") } else { // 負の場合の処理 print("numは偶数ではない") } // 表出結果 => numは偶数こんな感じで行う処理を分けて行きます!
また
if
文では、条件を複数設定することもできます。
それを可能にするのは、if ~ else if
です。書式はこんな感じ、、、
test.Playgroundif 条件式1 { // 正の場合の処理 } else if 条件式2 { // 正の場合の処理 } else { // 負の場合の処理 }この様に、
else if
を使うと条件式を複数使用できます。ちなみに、分岐の条件として「負の場合」と書いていますが、
どちらかといえば「正でない場合」と言い方が正しいかもしれません。switch ~ case
書式はこんな感じ、、、
test.Playgroundswitch 変数(または定数) { case 条件1: // 処理 case 条件2: // 処理 ・ ・ ・ default: // 全ての条件に一致しなかった場合の処理 }
switch ~ case
の条件分岐式では、対象になる変数(または定数)に
case
ごとに条件を設定して行き、if
と同様に
設定された条件に対して正(正しい)か、負(間違っている)かによって
行う処理をが変わる。
switch
の文はif
文とは違い、変数(または定数)の定義が
1回でいいので、複数の条件を設定する場合に有用である。実際の使用法は、、、
test.Playgroundvar age = 17 switch age { case 0...6: print("幼稚園児") case 7...12: print("小学生") case 13...15: print("中学生") case 16...18: print("高校生") default: print("社会人 or 大学生") } // 表出結果 => 高校生こんな感じになります!
guard
guard
文は上の2つの条件分岐とは違う判定を行う条件分岐式です。
しかし、swiftで開発を行う場合はとても有用で重要です!まず、書式はこんな感じ、、、
test.Playgroundguard 条件式 else { // 条件式が負の場合に処理を実行 }書式を見ての通り、
guard
文は「条件式が負の場合」に処理を実行します。
ちょうど上2つとは真逆の条件分岐式になりますね。初めて見ると「こいついるの?」と思うかもしれないが、
swiftでは割と重要なので知っておいて損はないと思います!
(個人の感想です‼︎)ちょっと難しいですが、実際に使うと、、、
test.Playground// 関数の定義 func test(_ a: Int) { //guard文定義 guard a > 0 else{ //条件式が負の場合に処理を実行 print("数値は0未満です") return } print a } // 関数に値を挿入 test(1) //表出結果 => 1今回使った関数の定義は次回説明します!
これまで
今回は、
if
、switch ~ case
、guard
の3つの条件分岐式
についてやりました。条件分岐式は開発に進めば頻繁に目にすることになるので、
この3つだけでも覚えておいてください‼︎
- 投稿日:2020-05-16T10:25:24+09:00
こんなソースコードはイヤだ-深いインデントを防ぐには
- 投稿日:2020-05-16T10:17:10+09:00
【Swift】カメラ起動
※余計なコードが入っている場合があります。
①photosをインポートする
② UIImagePickerControllerDelegate,UINavigationControllerDelegate を継承する。
UIImagePickerControllerDelegate,UINavigationControllerDelegate③カメラ起動させる。以下のコードをメソッドとして記述する。
//カメラを起動 func doCamera(){ let sourceType:UIImagePickerController.SourceType = .camera //カメラが利用可能かチェックする if UIImagePickerController.isSourceTypeAvailable(.camera){ let cameraPicker = UIImagePickerController() cameraPicker.sourceType = sourceType cameraPicker.delegate = self cameraPicker.allowsEditing = true present(cameraPicker, animated: true, completion: nil) } }④カメラ撮影した時に呼ばれる箇所。以下のコードを記述する
//カメラ撮影orアルバムから画像選択された時に呼ばれる func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { if info[.originalImage] as? UIImage != nil{ let selectedImage = info[.originalImage] as! UIImage UserDefaults.standard.set(selectedImage.jpegData(compressionQuality: 0.1), forKey: "userImage") logoImageView.image = selectedImage picker.dismiss(animated: true, completion: nil) } }
- 投稿日:2020-05-16T07:06:43+09:00
アニメ画像の昼/夜認識システムの作成:(2/3) そのモデルと「Vision」フレームワークを用いて新規画像からラベルを取得する。
本シリーズの記事一覧:
1.「Core ML」モデルを「Create ML」で既存のラベル付けされたアニメ画像を入力として用いてトレーニングする。
2. (本記事)そのモデルと「Vision」フレームワークを用いて新規画像からラベルを取得する。
3. それをもとに時間帯に合わせてmacOSの壁紙を変更する意図:
Mac OSの壁紙として、時刻に合わせてさまざまなアニメ画像を設定するアプリの開発。
機械学習を使用して、アニメ画像に自動的に昼または夜としてのマーク付けを行う。
アニメ画像から昼夜の状態を認識する必要があるため、機械学習を取り入れます。
このアプリは、例えば日中には昼の場面のさまざまなアニメ画像を壁紙として設定します。そして夜間には、夜の場面のさまざまなアニメ画像を壁紙に設定します。本記事
前項では、トレーニング済みの
.mlmodel
ファイルを取り上げましたが、本項では、作成されたモデルを使用して画像にラベルを付ける方法を学習します。Vision フレームワークについて
Vision は、コンピュータビジョンアルゴリズムを応用して、入力画像や動画に対してさまざまなタスクを実行するために使用されるフレームワークです。また、テキスト、レクタングル、顔、QRコードの領域を抽出するなど、多くの優れた機能を実行することができます。
Vision
フレームワーク内では関数VNCoreMLRequest
を使用します。init(model: VNCoreMLModel, completionHandler: VNRequestCompletionHandler?)ご覧のとおり、
VNCoreMLRequest
は機械学習モデルを取り込み、VNRequestCompletionHandler
に結果を通知します。
VNRequestCompletionHandler
の構造はこちらです:typealias VNRequestCompletionHandler = (VNRequest, Error?) -> Void
VNRequest
の構造を掘り下げると、そこにも結果が含まれていることがわかります。https://developer.apple.com/documentation/vision/vnrequest/2867238-results
実装
それではコードに取り掛かりましょう!
ステップ1.機械学習モデルの読み込み
上で学んだように、「VNCoreMLRequest」にはモデルインプット「VNCoreMLModel」が必要です。
まず、訓練済みのモデルファイル「.ml」をプロジェクトにコピーしなければなりません。確実にアプリケーションターゲットにコピーするようにしてください。そのファイルの名前を仮に「AnimeDayNight.mlmodel」としましょう。もちろん、「Apple」はすでに訓練済みのモデルを次の場所でたくさん提供しています: https://developer.apple.com/machine-learning/models/
今すぐモデルを読み込む:
// 生成済みのクラスを通してMLモデルを読み込んでください guard let model = try? VNCoreMLModel(for: モデルファイルの名前().model) else { fatalError("MLモデルを読み込めません") }Step 2.
VNCoreMLRequest
のセットアップ
VNCoreMLRequest
は画像を送る場所ではなく、画像認識からの結果を受け取る場所ですのでご注意ください。つまり、システムに実行すべきアクションを伝えるためのものです。let request = VNCoreMLRequest(model: model) { [weak self] request, error in guard let results = request.results as? [VNClassificationObservation], let topResult = results.first else { fatalError("結果がありません") } let detectedResult = topResult.identifier if detectedResult == "昼間"{ //TODO } else if detectedResult == "夜"{ //TODO } }Step 3. 画像データの提供
VNImageRequestHandler
はリクエストに対して画像データを供給し、分析を実行するために使われます。機械学習分析には「ciImage」、「cgImage」、または「CVPixelBuffer」が必要になります。
init(cgImage: CGImage, options: [VNImageOption : Any])init(ciImage: CIImage, options: [VNImageOption : Any])init(cvPixelBuffer: CVPixelBuffer, options: [VNImageOption : Any])機能に対して「orientation 方向性」プロパティの活用も可能であことにもご留意ください。これは画像の方向性になります。画像の方向性を渡すことは好ましいです。本記事ではこれについては詳しくは言及しません。
次のものを使うことで「UIImage」から「ciImage」を取得することができます:
let ciImage = UIImage(named: "test.png")!.ciImageこれでリクエストを実行できます:
let handler = VNImageRequestHandler(ciImage: image) DispatchQueue.global(qos: .userInteractive).async { do { try handler.perform([request]) } catch { print(error) } }分析が終われば、リクエストハンドラーがステップ2で宣言されます。
他の機械学習モデル
他の機械学習モデルを使用している場合は、結果処理の部分のみ変更が必要です。
Xcode内の機械学習モデルのファイルをクリックすると、モデルに入力すべきデータと、モデルから出力されてくると思われるデータの詳細が表示されます。
デモアプリケーション
私がトレーニングした機械学習モデルは、わずか15枚ほどの画像を学習したものです。非常に正確性が低いので、手順の紹介目的のみで使用しています。
iOS version (画像ギャラリーから画像を選択)
コードファイル: https://github.com/mszopensource/SwiftVision/blob/master/iOS%20画像/iOS%20画像/ViewController.swift
プロジェクト全体: https://github.com/mszopensource/SwiftVision/tree/master/iOS%20画像
macOS version (画像ギャラリーから画像を選択)
プロジェクト全体: https://github.com/mszopensource/SwiftVision/tree/master/MacOS%20機械学習
今後の記事について
本シリーズの次回の記事では、時刻に基づいて異なるアニメ画像を壁紙に自動的に設定するMac OSアプリの開発について紹介します。前回と今回の記事に基づいた内容となっています。