20200516のSwiftに関する記事は12件です。

iOSリバーシリファクタリングチャレンジ w/ Redux

リバーシリファクタリングチャレンジ

koherさんが公開された、このFat View Controller、あなたはリファクタリングできますか?チャレンジに参加しました。

本チャレンジは、 Fat View Controller として実装されたリバーシアプリをリファクタリングし、どれだけクリーンな設計とコードを実現できるかというコンペティションです(ジャッジが優劣を判定するわけではなく、設計の技を競い合うのが目的です)。

すばらしいチャレンジを用意くださったkoherさんを始め、運営のお手伝いをされているtakasekさん、Ogawaさんの皆様に感謝です。

リファクタリング結果

以下のGitHubリポジトリにリファクタリングした結果を公開しています。masterブランチがリファクタリング済みになります。
この記事にもコードを記載していますが全体の8割ぐらいです。

リファクタリング方針

ゼロから作り直すやり方ではなく、オリジナルのリバーシ部分のロジックを踏襲しつつ、少しずつ動作を確認しながらリファクタリングを実施しました。

iOSアプリ設計パターン入門にて、Reduxアーキテクチャの章を執筆したので、Reduxアーキテクチャを適用したリファクタリングにチャレンジします。ターン制のゲームはユーザーやコンピュータのアクションによりゲームの状態が変化し、状態の変化に合わせたビューの表示を行う流れは、Reduxアーキテクチャと相性が良いと感じました。

リファクタリング観点

以下のような観点を意識しながら、リファクタリングを行いました。
ただ、各観点を最大化することが目的ではなく、適切な範囲にとどめ全体最適を目指すことが大事だと思っています。

  • 階層、分断、排他、網羅を意識した構造化
  • 影響範囲の局所化
  • 型表現や命名による意味付け
  • 単一責務化
  • 制約化
  • 抽象化
  • 共通化

アーキテクチャ導入

Reduxアーキテクチャ構造

リバーシアプリに適用したReduxアーキテクチャの構造は以下の図のようになります。
また、Reduxアーキテクチャを支援するReSwiftライブラリを導入しました。

ReduxArchitecture.001.jpeg

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 and Side
  • 役割を担うプリミティブなデータ構造をデータ型と命名を与え意味付する
    • 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は手詰まりのアラートをシステムから表示する場合も割り込みとして表現しました。

既存コード リファクタリングコード
リセットアラートが表示中でもゲームが進行する リセットアラートが表示中だとゲームが進行しない
May-16-2020 02-26-45.gif May-16-2020 02-16-11.gif

Redux

AppStateとAcrtionのすべてのコードはGitHub repositoryで確認できます。

AppState

AppStateはViewから参照される状態の起点になるStateです。

GameProgressAppStateの内部状態から現在あるべき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

ActionCreatorReducerのように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)はどんな変更であろうと通知され、表示を変更する処理を記載
  • subscriberGameProgressGameProgressに変更があった場合のみ通知され、GameProgressの状況をパターンマッチのうえ、各状況における適切な処理を記載
  • subscriberBoardContainerBoardContainerに変更があった場合のみ通知され、盤面の変更があった場合に盤面の表示を変更する処理を記載
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: 4

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

スクリーンショット 2020-05-16 19.43.22.png

既知の不具合への対処

本チャレンジではリファクタリング対応にとどまらず、潜在する不具合を発見し的確に対処する必要があります。

手詰まりしたときの要対応事象 その1

手詰まりになった場合、アラートが表示された状態でアプリを終了すると、再度アプリを起動して前回終了時をロードしたときにクラッシュします。

スクリーンショット 2020-05-10 1.23.58.png

スクリーンショット 2020-05-10 0.45.39.png

手詰まりしたときの要対応事象 その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()!

May-10-2020 01-53-57.gif

リセットアラートの表示時の要対応事象

リセットボタンを押下し、リセットの確認アラートを表示している状態でもコンピュータの操作は可能なのでゲームは進行していきます。このとき、手詰まりが発生すると、手詰まりのアラートを表示すべきところですが、すでにリセットの確認アラートを表示しているためリセットの確認アラートを表示できず操作ができない状態に陥ります。

May-10-2020 01-49-49.gif

リグレッションしないように気を付けるところ

  • 1. コンピュータが思考中にリセットした場合、リセット後のゲームでコンピュータの思考中が継続され誤ってディスクを指さないこと
  • 2. フリップアニメーション中にリセットした場合、リセット後のゲームでフリップアニメーションが継続されないこと
1. 2.
May-16-2020 01-35-13.gif May-10-2020 01-59-13.gif

まとめ

反省点

  • Reduxアーキテクチャに移行にあたって、リファクタリングがおおむね完了するまでテストコードを導入できなかった
    • 移行が完了しないとReduxを活かしたテストコードを導入できなかったため
    • テストコードでリファクタリングの過程にリグレッションしないことを担保したかった
  • リファクタリング前と後でコードステップ数を計測すると67%増加していた
    • Reduxのメリットの堅牢を求めるあまり、複雑なコードになったり過剰なリファクタリングとなった部分が否めない

コードステップ数

- リファクタリング前 リファクタリング後
ステップ数 775 1,295

未完成部分

今後の課題です。

  • テストコードの記述がほとんどありません、もっとテストを書くべき!
  • システム的な異常系のハンドリングはpreconditionFailureassertionFailureの活用にとどまっており、ユーザーに異常状態を通知できていない

感想

リバーシという題材のチョイスがよく程よいボリューム感とリバーシロジックの難易度があり、ベースコードのファトコードの再現具合も絶妙で多様なアーキテクチャでリファクタリングのアプローチが可能なうえ、噛めば噛むほど味の出るすばらしいチャレンジだと感じました。

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

5 月 LeetCode 挑戰, W3D2, Odd Even Linked List (Linked List), in Swift

資料結構與演算法: 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
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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()
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

データを他のアプリへ送る(Swift5)

概要

めちゃくちゃお久しぶりです。書こう書こうと思ってるとすぐ時間が過ぎる・・・

さて、SNSへ投稿したりするあれです。
SNSだけじゃなくて対応したアプリ(自作含め)は表示されますので好きなアプリに送信すれば良いわけです。
今回はサンプル画像をメモに送って確認したいと思います。
IMG_1450.TRIM 2.gif

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だとそもそもコントローラーが表示されなかったりしました。
なんで違いが・・・違いが出る理由がわかりませんが参照関係にある(あるべき)オブジェクトの生存期間の違いによるものだと思われます。詳しい方教えてもらえたら嬉しいです・・・

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

ストーリボードでダークモード対応のカラーとイメージを使ってアプリを作る方法

はじめに

最近プロジェクトでStroyboardを使う機会ができ、Stroyboardでカラーやイメージにダークモード対応する方法を調べたので、メモとして記事を書いてみました
今回の記事はStoryboardのみなので、コードは全くないです

今までコードでUIを作ってきたので、iOSエンジニア2年目でStoryboardの便利さに気づきましたw

※Xcodeのバージョンは11.2.1です

ダークモードの設定

Storyboardでダークモード対応するには、事前にAssetsで設定する必要があります
カラーとイメージそれぞれの設定からしましょう
※今回はPDFファイルのイメージを使ってます

カラー

まずは右クリクで「New Color Set」を選択して、Assetsに新しいColor Setを用意する
※Color Setの名前は好きな名前にする(サンプルではTextColorにしいてる)
1.png

Utility AreaのAppearancesを「Any, Light, Dark」に変更してDark Modeの色を設定できるようにする
2.png

写真のようにColor Setの左2つに普段のカラーを、右のDark AppearanceにDark Modeのカラーを設定する
11.png

イメージ

まずは右クリクで「New Image Set」を選択して、Assetsに新しいImage Setを用意する
※Image Setの名前は好きな名前にする(サンプルではbackgroundにしている)
3.png

PDFファイルの画像を1枚挿入して、Utility AreaのPreserve Vector Dataにチェックをいれる
※チェックをいれるのはPDFファイルの画像を利用しているため
4.png

Utility AreaのScalesを「Single Scale」に変更する
※PDFファイルの場合は画像を1枚しか利用しないため
5.png

Utility AreaのAppearancesを「Any, Light, Dark」に変更してDark Modeのイメージを設定できるようにする
6.png

写真のようにImage Setの上2つに普段のイメージを、下のDark AppearanceにDark Modeのイメージを設定する
12.png

ダークモードのアプリを作る

Main.storyboardでUIImageViewとUILabelを追加してレイアウトを設定した前提で次にすすむ

StoryboardのUIにダークモード対応のカラーとイメージを設定する

UIImageViewのImageにダークモード対応のイメージを設定する
Utility AreaのImageViewのImageで設定したImage Setを選択する(サンプルの場合はbackground)
8.png

UIlabeの文字色にダークモード対応のカラーを設定する
Utility AreaのLabelのColorで設定したColor Setを選択する(サンプルの場合はTextColor)
9.png

完成アプリ

iPhoneの設定でダークモードを切り替えると下のように切り替わります
10-1.png10-2.png

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

Swift学習記#4「関数の定義」

はじめに

今回はswiftにおける関数の定義方法について書いて行きます!

関数は初めて触ると、どの様にデータが流れているのか
よくわかりませんよね!僕も最初はそうでした!

なのでここでは、基礎的な定義とともに
引数戻り値なんかについても覚えて、
データの流れをしっかり掴みましょう!

関数とは

関数(function)とは、
簡潔に言うと「特定の処理を行うためのまとまり」です。

他の言語でも用いられていることが多いので、
他の言語を学習したことのある方はすでにご存知かと思います。

関数の定義

書式はこんな感じ、、、

test.Playground
func 関数名(引数名1:引数1の型, 引数名2:引数2の型, ...)-> 戻り値の型{

    return //戻り値

}

定義の中にある引数というのは、「関数に渡す値」のことで
渡す値は、その後に書くコードの中で指定します。

具体例はこんな感じ、、、

test.Playground
func 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.Playground
func 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.Playground
func 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 - 0

inoutによる参照渡し

swiftでは、関数を定義する際に( msg : inout String )の様に
データ型の前にinoutをつけ、引数を設定する際に( msg: &s )の様に
引数の値の前に&をつけると参照渡すを行うことができる。

*参照渡しとは

 通常の引数は、関数外の変数の値をコピーして渡す値渡しを行うため、
 関数外の変数の値に変化はない
 しかし参照渡しの場合は、関数ないで変数の値を変更すると、
 その変更を関数外にある元の変数と共有することになる

具体例はこんな感じ、、、

test.Playground
func 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になり、
基礎知識もあらかたついた様な気もするので、
これからはアプリを開発しながらの学習をやって行きます!

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

TwitterのプロフィールであるようなUITableViewのヘッダーの画像を引っ張って拡大させる機能をたった数行のコードで実装する

Tiwtter等のアプリでよくあるプロフィール画面のカバー画像を下に引っ張ると拡大されるPrallaxHeader機能(っていうんですかね?)ですが、
実はこれめちゃくちゃ簡単に実装できます。

完成系はこんな感じです。
ezgif-7-b4b7b38acba0.gif

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でみるとよくわかると思います。
スクリーンショット 2020-05-16 14.44.34.png

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を配置し、必要な箇所の背景色を透明にして制約をいじるだけなので割と既存の実装に反映させやすいと思います。
また、スクロール位置に合わせてブラーを追加等も機能も簡単に追加できると思うのでおすすめです。

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

SwiftでTwitterのプロフィールであるようなUITableViewのヘッダーの画像を引っ張って拡大させる機能をたった数行のコードで実装する

Tiwtter等のアプリでよくあるプロフィール画面のカバー画像を下に引っ張ると拡大されるPrallaxHeader機能(っていうんですかね?)ですが、
実はこれめちゃくちゃ簡単に実装できます。

完成系はこんな感じです。
ezgif-7-b4b7b38acba0.gif

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でみるとよくわかると思います。
スクリーンショット 2020-05-16 14.44.34.png

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を配置し、必要な箇所の背景色を透明にして制約をいじるだけなので割と既存の実装に反映させやすいと思います。
また、スクロール位置に合わせてブラーを追加する機能等も簡単に追加できると思うのでおすすめです。

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

Swift学習記#3「条件分岐式について」

はじめに

今回は、前回の繰り返し文と同じく開発の中で頻繁に用いられる
条件分岐についてです。

その中でも、僕が「特に使うのではないか」と思う
ifswitch ~ caseguardの3つについて解説して行きます!

if ( if ~ else, if ~ else if )

書式はこんな感じ、、、

test.Playground
if 条件式 {

  // 正の場合の処理

} else {

  // 負の場合の処理

}

if文の条件分岐式は、対象になる変数(または定数)が
設定された条件に対して正(正しい)か、負(間違っている)か
によって行う処理が変わるというもの。

実際には、

test.Playground
var num = 12

if num % 2 = 0 {
  // 正の場合の処理
  print("numは偶数")

} else {
  // 負の場合の処理
 print("numは偶数ではない")
}

// 表出結果
=> numは偶数

こんな感じで行う処理を分けて行きます!

またif文では、条件を複数設定することもできます。
それを可能にするのは、if ~ else ifです。

書式はこんな感じ、、、

test.Playground
if 条件式1 {

  // 正の場合の処理

} else if 条件式2 {

  // 正の場合の処理

} else {

  // 負の場合の処理

}

この様に、 else ifを使うと条件式を複数使用できます。

ちなみに、分岐の条件として「負の場合」と書いていますが、
どちらかといえば「正でない場合」と言い方が正しいかもしれません。

switch ~ case

書式はこんな感じ、、、

test.Playground
switch 変数(または定数) {
case 条件1:
  // 処理
case 条件2:
  // 処理

    ・
    ・
    ・

default:
  // 全ての条件に一致しなかった場合の処理
}

switch ~ caseの条件分岐式では、対象になる変数(または定数)に
caseごとに条件を設定して行き、ifと同様に
設定された条件に対して正(正しい)か、負(間違っている)かによって
行う処理をが変わる。

switchの文はif文とは違い、変数(または定数)の定義が
1回でいいので、複数の条件を設定する場合に有用である。

実際の使用法は、、、

test.Playground
var 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.Playground
guard 条件式 else {

  // 条件式が負の場合に処理を実行

}

書式を見ての通り、
guard文は「条件式が負の場合」に処理を実行します。
ちょうど上2つとは真逆の条件分岐式になりますね。

初めて見ると「こいついるの?」と思うかもしれないが、
swiftでは割と重要なので知っておいて損はないと思います!
(個人の感想です‼︎)

ちょっと難しいですが、実際に使うと、、、

test.Playground
// 関数の定義
func test(_ a: Int) {

//guard文定義
  guard a > 0 else{

  //条件式が負の場合に処理を実行
    print("数値は0未満です")
    return
  }
  print a
}

// 関数に値を挿入
test(1)

//表出結果
=> 1

今回使った関数の定義は次回説明します!

これまで

今回は、ifswitch ~ caseguardの3つの条件分岐式
についてやりました。

条件分岐式は開発に進めば頻繁に目にすることになるので、
この3つだけでも覚えておいてください‼︎

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

こんなソースコードはイヤだ-深いインデントを防ぐには

プログラムのソースコードを書く時のアンチパターンをまとめていこうと思います。

深いインデントを防ぐには

sample.swift
 func execute() {
    let user = self.getUser()
    if (user.authenticated) {
      let connection = user.getConnection()
      if (connection.enable) {
        let data = connection.getData()
        if (data.exists) {
          print(data.string)
        }
      }
    }
  }

どんなリファクタリングができるのか?

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

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

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

アニメ画像の昼/夜認識システムの作成:(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内の機械学習モデルのファイルをクリックすると、モデルに入力すべきデータと、モデルから出力されてくると思われるデータの詳細が表示されます。

Screen Shot 2020-05-15 at 17.21.17.png

デモアプリケーション

私がトレーニングした機械学習モデルは、わずか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画像

Smiley face

macOS version (画像ギャラリーから画像を選択)

コードファイルhttps://github.com/mszopensource/SwiftVision/blob/master/MacOS%20機械学習/MacOS%20機械学習/ViewController.swift

プロジェクト全体https://github.com/mszopensource/SwiftVision/tree/master/MacOS%20機械学習

今後の記事について

本シリーズの次回の記事では、時刻に基づいて異なるアニメ画像を壁紙に自動的に設定するMac OSアプリの開発について紹介します。前回と今回の記事に基づいた内容となっています。

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