- 投稿日:2020-07-05T20:56:34+09:00
[iOS, Swift] シングルトンの使い所について考察してみる
本稿では、Swiftにてオブジェクト指向のデザインパターンの1つである 「シングルトン(Singleton)パターン」 をどう活用するのが良いか、考察してみたいと思います.
はじめに
この記事では以下について紹介します。
- シングルトンとは?
- iOSにおけるシングルトンの実装例
- 画像処理の例で考察
使用環境は iPhoneXs (実機, iOS 13.5), Xcode 11.5 です.
シングルトンとは?
Wikipediaによると、次のように定義されています.
Singleton パターンとは、そのクラスのインスタンスが1つしか生成されないことを保証するデザインパターンのことである。ロケールやルック・アンド・フィールなど、絶対にアプリケーション全体で統一しなければならない仕組みの実装に使用される。
つまりシングルトンは基本的に、アプリケーションのコアとなる情報が無闇に複製されることを制限し、Globalで同一の情報を参照するための機構として用いるもののようです.
このような機構は特に、複数スレッドから同一情報にアクセスする場合などにおいて重宝されそうです.また以下の記述も見られます.
扱い次第では、グローバル変数のように機能させることもできる。
つまりGlobal空間において複製禁止の状態を保持するために用いることもできると.
Swiftではどのような場面がこれに該当するでしょうか.Swiftでシングルトンパターンが有効な場面
Swiftではこちらの記事にあるように、クラスや構造体にて static 変数やメソッド (インスタンス化せず使用できる変数およびメソッド) を用いることで、シングルトンパターンを用いずともシングルトンのような振る舞いを得ることができます. 状態の授受を担う機能のみが必要な場合は、この実装で事足ります.
しかしながらこの方法では、授受された情報をクラスや構造体の中で何かしら操作したい場合には問題が生じ得ます. すなわち、クラスや構造体のstatic変数はメンバ変数として用いることはできず、またメンバ変数はstaticメソッドでは使用することができません.
総合すると、以下のような場合がシングルトンパターンを使用したい場面であると言えると思います.
- Global空間にデータを保持したい.
- 保持されたデータ (の参照先) の複製を禁止したい.
- クラスないし構造体にデータを保持し、これを制御するメソッドを設けたい.
私が関わる画像処理の分野では特に後述の通り、ポインタを用い直接的にメモリを制御する場面によく出くわすのですが、このような場合にはシングルトンパターンを活用した方が良いと思える場合があります.
iOSにおけるシングルトンの実装例
Swiftではクラスや構造体の仕様上、static修飾子を活用することで簡単にシングルトンパターンを実現することができます.
class mySingleton { private init() { } // 外部からのインスタンス化を禁止するために private 指定. public static var singleton = mySingleton() // static変数で、初期化済みの自分自身のインスタンスを保持. }static修飾子は変数に用いる場合、厳密には「明示的に解放、ないしクラスの属性である場合にはそのクラスがアンロードされない限り、メモリ割り当て後からアプリケーションの終了までメモリを保持しておく」 ことを示します.
この仕組みを活用することで、常に一定のインスタンスへアクセスすることができる訳ですね.シングルトンの実際の使用例は次章に示します.
シングルトンの使用例
以下では、Stack Overflowの記事に記載されたコードの問題点について言及し、この問題をシングルトンを用いて解決する方法について解説します.
例: MTLTextureをCGImageに変換する
上記リンクではMTLTextureオブジェクトをCGImageオブジェクトに変換するため、以下のようなMTLTexture クラスの extension コードが示されています.
import Metal import MetalKit extension MTLTexture { func bytes() -> UnsafeMutablePointer<Void> { let width = self.width let height = self.height let rowBytes = self.width * 4 let p = malloc(width * height * 4) self.getBytes(p, bytesPerRow: rowBytes, fromRegion: MTLRegionMake2D(0, 0, width, height), mipmapLevel: 0) return p } func toImage() -> CGImage? { let p = bytes() let pColorSpace = CGColorSpaceCreateDeviceRGB() let rawBitmapInfo = CGImageAlphaInfo.NoneSkipFirst.rawValue | CGBitmapInfo.ByteOrder32Little.rawValue let bitmapInfo:CGBitmapInfo = CGBitmapInfo(rawValue: rawBitmapInfo) let textureSize = self.width * self.height * 4 let rowBytes = self.width * 4 let provider = CGDataProviderCreateWithData(nil, p, textureSize, nil) let cgImageRef = CGImageCreate(self.width, self.height, 8, 32, rowBytes, pColorSpace, bitmapInfo, provider, nil, true, CGColorRenderingIntent.RenderingIntentDefault)! return cgImageRef } }*(Metalについては拙著などをご覧ください)
このコードには大きな欠陥があります.
let p = malloc(width * height * 4)
の通り、データ変換のための中間情報としてメモリ領域を確保してますが、mallocで確保されたメモリ領域はC言語と同様に明示的に解放 (dealloc) する必要があります.
このコードではメモリ解放を行っていないため、この関数が呼ばれる度にメモリがスタックされていき、最終的には容量超過でアプリケーションがクラッシュします.ではコードに dealloc を組み込めば良いだけじゃないか、と思われるかもしれませんが実は今回のコードでは、以下の理由からそう単純には実現できません.
cgImageRef
オブジェクト (CGImageクラスのインスタンス) はポインタp
を参照しています. そしてSwiftでは言語の仕様としてクラスインスタンスは必ず参照渡しになります. したがってこのcgImageRef
オブジェクトは return された後も常にポインタp
を参照し続けます.すなわちポインタ p をdeallocすると同時に cgImageRef のデータも消失する事になるため、もしextensionの関数中において単純に dealloc を組み込んだとしても実行エラーが発生してしまいます.
このようなメモリの危険な振る舞いを適切に保守管理できるよう、できるだけミニマルな記述で解決したいところです.以上を鑑み、アプリケーション全体で用いられる1つ以上の画像データのポインタを一元管理するために複製が制限されたクラスが必要となりそうです. すなわちシングルトンパターンが活用できる場面であることが分かりました.
シングルトンの実装例
上記コードではポインタ
p
をextention内部だけで適切に扱うことが実質不可能である構図がみて取れました. したがって、ポインタp
をextensionの外で扱うクラス (シングルトン) を設け、適切なタイミングでポインタを dealloc できるようにする以下のような修正を行ってみました.final class ImgPtrManager { private let num_cached: Int = 8 // キャッシュしておくポインタの数. アプリケーションに応じて数値を設定. private var p: Array<UnsafeMutableRawPointer?> = [] // 件のポインタをシングルトンで保持 (キャッシュ) する. private init() { } public static let singleton = ImgPtrManager() // Swiftのシングルトンパターン. // push & shift の要領で古いデータを削除する public func manageCachedMem(ptr: UnsafeMutableRawPointer) { if p.count >= num_cached { do { p.first!!.deallocate() } p.removeFirst() } p.append(ptr) } } extension MTLTexture { public func convTexToCGImg() -> CGImage { let ptr = self.bytes() ImgPtrManager.singleton.manageCachedMem(ptr: ptr) // シングルトンを用いてメモリを管理する. return toImage(p: ptr)! } // 以下は概ね変更なし (ポインタpの受け渡し方法だけ少し変更) fileprivate func bytes() -> UnsafeMutableRawPointer { let width = self.width let height = self.height let rowBytes = self.width * 4 let p = malloc(width * height * 4) self.getBytes(p!, bytesPerRow: rowBytes, from: MTLRegionMake2D(0, 0, width, height), mipmapLevel: 0) return p! } fileprivate func toImage(p: UnsafeMutableRawPointer) -> CGImage? { let pColorSpace = CGColorSpaceCreateDeviceRGB() let rawBitmapInfo = CGImageAlphaInfo.noneSkipFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue let bitmapInfo:CGBitmapInfo = CGBitmapInfo(rawValue: rawBitmapInfo) let selftureSize = self.width * self.height * 4 let rowBytes = self.width * 4 let releaseMaskImagePixelData: CGDataProviderReleaseDataCallback = { (info: UnsafeMutableRawPointer?, data: UnsafeRawPointer, size: Int) -> () in return } let provider = CGDataProvider(dataInfo: nil, data: p, size: selftureSize, releaseData: releaseMaskImagePixelData) let cgImageRef = CGImage(width: self.width, height: self.height, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: rowBytes, space: pColorSpace, bitmapInfo: bitmapInfo, provider: provider!, decode: nil, shouldInterpolate: true, intent: CGColorRenderingIntent.defaultIntent)! return cgImageRef } }終わりに
いかがでしたでしょうか? 参考になれば幸いです!
ご意見や改善点などあれば、どしどしコメントください!
- 投稿日:2020-07-05T18:57:44+09:00
imageViewをタップできるようにしょう!
- 投稿日:2020-07-05T18:57:44+09:00
imageViewをタップする
- 投稿日:2020-07-05T17:41:22+09:00
UIColorとIntの相互変換
n番煎じですが、Intとの相互変換についての日本語の記事が見当たらなかったため投稿します
使い方
let color = UIColor(hex: 0xff0000) // red color.hex // 0xff0000extension UIColor { convenience init(hex: Int) { let red = (hex & 0xff0000) >> 16 let green = (hex & 0x00ff00) >> 8 let blue = hex & 0x0000ff self.init( red: CGFloat(red) / 255, green: CGFloat(green) / 255, blue: CGFloat(blue) / 255, alpha: 1 ) } var hex: Int { var red = CGFloat(0) var green = CGFloat(0) var blue = CGFloat(0) getRed(&red, green: &green, blue: &blue, alpha: nil) return Int(red * 255) << 16 + Int(green * 255) << 8 + Int(blue * 255) } }
- 投稿日:2020-07-05T17:15:12+09:00
SILを見てたら?(SUPERHERO)が出てきた
最初の方はSILやOpaque Return Typeの説明になっています。?に早くお目にかかりたい場合は適当に飛ばしましょう。
SIL
Swiftをコンパイルする時、まずコンパイラはソースをSILと呼ばれる中間言語に翻訳する。これは最適化に適している言語であり、Swiftに限らず多くの言語はこうした中間言語を持っている。SILは最適化を受けた後、LLVMIRと呼ばれる形になり、LLVMというローレベルコンパイラにてバイナリにされる。LLVMは言語に依存しないコンパイラであり、SwiftがSwiftとしての最適化が行えるのがSILという訳だ。Swift固有の機能がコンパイラ内部でどう処理されるかを確認したい場合はSILを確認すると良い。
Opaque Return Type
Swift 5.1で追加されたopaque return typeという機能がある。opaque typeはジェネリクスの一種で、ジェネリクスの中でも単純なものをより簡単に書けるようにしたものである。
ジェネリクスは総称型と訳されるが、こと関数の返り値におけるジェネリクス(リバースジェネリクスという)は総称型というより実装を抽象化するためのものである。
例えばある型が
getHash
という関数を持っていた時、大抵の場合getHash
の返す型がInt
であるかString
であるかはたまたArray<Float>.Iterator.Element
(あまり良い例が思いつかなかった。)であるかは必要な情報ではない。getHash
がある型を返し、それがハッシュ値として機能することが分かれば良いのだ。ここで出てくるのがジェネリクスである。ジェネリクスを用いると「Hashableプロトコルに適合しているなんらかの型」を返す関数を作成できる。例えばこのように。
func getHash() -> <T: Hashable> T { // Intを返す }この例において
getHash
はInt
を返すが、getHash
を呼び出す側は返ってくる値がInt
だとはわからない。Hashable
に適合したなんらかの値が返ってくることだけがわかる。let x = getHash() // xの型はIntではない。現在のSwiftではこのような書き方(リバースジェネリクス)は許されていないが、opaque return typeを使うことによって同じ効果を得ることができる(今回は問題ないが、リバースジェネリクスの下位互換であることに注意。)。opaque return typeはSwiftUIで効果的に使えるため(SwiftUIでViewを記述する時、bodyプロパティの型は非常に細かく膨大になものになってしまう。詳細な型情報は必要ないためジェネリクスの出番というわけだ。また、詳しくは説明しないがリバースジェネリクスである必要はない。)、Appleはリバースジェネリクスより一足先にopaque return typeを導入したのだ。opaque return typeはこう記述する。
func getHash() -> some Hashable { ... }これは「とあるHashableな値を返す」という意味であり、someというキーワードを使用してそれを表す。
SILでのOpaque Return Type
ところでopaque return typeの本当の型はコンパイラだけが知っている。これがSwiftの中間言語であるSILでどのように表現されているか気になるだろう。swiftcコマンドにはSILを見るオプションが備わっている。
swiftc ファイル.swift -emit-sil
だ。先程のgetHash
をSILにしてみよう。中身を書かないわけにはいかないので、常に1
を返すようにした。一体なんのハッシュだというツッコミは気にしないでおこう。
SIL(まあまあ長いので畳んでみた。)
(わかりやすいようにSwiftのシンタックスハイライトを適用している。)
swiftsil_stage canonical import Builtin import Swift import SwiftShims func getHash() -> some Hashable // main sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 { bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>): %2 = integer_literal $Builtin.Int32, 0 // user: %3 %3 = struct $Int32 (%2 : $Builtin.Int32) // user: %4 return %3 : $Int32 // id: %4 } // end sil function 'main' // getHash() sil hidden @$s4main7getHashQryF : $@convention(thin) () -> @out @_opaqueReturnTypeOf("$s4main7getHashQryF", 0) ? { // %0 // user: %3 bb0(%0 : $*Int): %1 = integer_literal $Builtin.Int64, 1 // user: %2 %2 = struct $Int (%1 : $Builtin.Int64) // user: %3 store %2 to %0 : $*Int // id: %3 %4 = tuple () // user: %5 return %4 : $() // id: %5 } // end sil function '$s4main7getHashQryF' // Int.init(_builtinIntegerLiteral:) sil public_external [transparent] [serialized] @$sSi22_builtinIntegerLiteralSiBI_tcfC : $@convention(method) (Builtin.IntLiteral, @thin Int.Type) -> Int { // %0 // user: %2 bb0(%0 : $Builtin.IntLiteral, %1 : $@thin Int.Type): %2 = builtin "s_to_s_checked_trunc_IntLiteral_Int64"(%0 : $Builtin.IntLiteral) : $(Builtin.Int64, Builtin.Int1) // user: %3 %3 = tuple_extract %2 : $(Builtin.Int64, Builtin.Int1), 0 // user: %4 %4 = struct $Int (%3 : $Builtin.Int64) // user: %5 return %4 : $Int // id: %5 } // end sil function '$sSi22_builtinIntegerLiteralSiBI_tcfC'SILの説明まですると日が暮れてしまうし、何より僕もそんなに知らないので簡単になってしまうが、中程に
// getHash()
という行があり、ここから10行程度先ほどの関数がSILで書かれている。(ちなみに上にあるmain
はこのプログラムのエントリーポイントを表しており、今回は0、つまり成功を返している。下のInt.init(_builtinIntegerLiteral:)
は1
というリテラルをInt
型に変換している。)
$s4main7getHashQryF
というのはgetHash
をマングリングしたもの(一意な表現にした関数名)で、その型が:
の後に書いてある。この関数の型は@convention(thin) () -> @out @_opaqueReturnTypeOf("s4main6opaqueQryF", 0) ?
だ。今回注目すべきは@_opaqueReturnTypeOf
というアノテーション。この中に関数名と0
が記述されている。
@_opaqueReturnTypeOf("s4main7getHashQryF", 0)
opaque return typeは各関数では一つの型だが、同じ
some Hashable
を返す関数でもそれが違う関数であれば同じ型とは言えない。例えば、func getValue() -> some Equatable { Bool.random() } let x = getValue() let y = getValue() print(x == y)はコンパイルできるが、
func getValue() -> some Equatable { Bool.random() } func getAnotherValue() -> some Equatable { Int.random(in: 1...10) } let x = getValue() let y = getAnotherValue() print(x == y)はコンパイルできない。この例だと当たり前に見えるかもしれないが、
func getValue() -> some Equatable { Bool.random() } func getAnotherValue() -> some Equatable { Bool.random() } let x = getValue() let y = getAnotherValue() print(x == y)も出来ない。これはopaque return typeの型は関数ごとに決まっており、なんらかの型で表すことは不可能であることを意味する。よってSILでの表現も
@_opaqueReturnTypeOf("s4main7getHashQryF", 0)
と関数名を含んだ表現になっているのだろう。(
0
はTupleの添字を表しているのではないかと思っているが、正確なところはわからない。func f() -> (some Equatable, Int)
を試してみたが、コンパイラに怒られた。どうも今のところTupleの中でopaque return typeは使えないようだ。将来的にサポートされるのかも不明である。)?
しかし?とはなんなのだろうか。Unicode名はSUPERHEROらしい。opaque return type以外のところで見たことはない。そもそも型情報の一部なのかも疑問である。
:
より後、{
より前にあるのでそう判断したが、何もかもがわからない。Swiftレポジトリをきちんと読むしかないのだろうか。
- 投稿日:2020-07-05T16:29:48+09:00
SwiftUI で星型のシェイプを作ってみる
はじめに
昔の記事で、UIKit で星型の図形を作ってみたことがあったのですが、勉強ついでに SwiftUI でも試してみました。次のようなビューを作成してみます。
正五角形 正六角形
正七角形 正八角形 環境
- Xcode Version 11.5 (11E608c)
実装方針
星型の図形は、次の方法で作図します。
- 星型の多角形の外接円と内接円を作る
- 外接正多角形の頂点の数 n に対して、円を 2n 等分の扇形に分割する直線を引く
- 直線と外接円の交点、内接円の交点を交互に取得する
あとは、取得した頂点を直線で結ぶだけです。正五角形の場合をアニメーションで表すとこんな感じ。
基本的なデータ型を実装する
まず、SwiftUI に依存しないデータ構造を実装します。この準備をすることで、座標の位置計算など、SwiftUI フレームワークに依存しない処理を分離することができます。
PolarCoordinate.swiftimport CoreGraphics /// 極座標 struct PolarCoordinate { /// 半径 var radius: Double /// 角度( 弧度法 ) var angle: Double init(radius: Double = 1, angle: Double = 0) { self.radius = abs(radius) self.angle = angle / (2 * .pi) } } extension PolarCoordinate { /// CoreGraphics の座標系における点 var cgPoint: CGPoint { CGPoint(x: radius * cos(angle), y: radius * sin(angle)) } } extension PolarCoordinate { /// 回転 func rotated(by angle: Double) -> Self { var point = self point.angle += angle return point } /// 拡大・縮小 func scaled(by t: Double) -> Self { var point = self point.radius *= t return point } }
PolarCoordinate
は極座標系における1点を表す構造体です。半径radius
と角度angle
をプロパティとして所持しています。最終的には、平面上の点として扱うことになるので、次のように、半径と角度から
CGPoint
を算出できるようにします。var cgPoint: CGPoint { CGPoint(x: radius * cos(angle), y: radius * sin(angle)) }星型の図形は多角形なので、極座標の点の集まりと、それを任意の位置に平行移動させるための中心座標の組として表現します。次のような構造体
StarParameters
として実装します。StarParameters.swiftimport CoreGraphics /// 星の頂点を表現した構造体 struct StarParameters { /// 中心座標 private(set) var center: CGPoint = .zero /// 極座標系の点群 private(set) var points: [PolarCoordinate] /// CoreGraphics の座標系における点群に変換する var cgPoints: [CGPoint] { let translatedByCenter = { [center] (point: CGPoint) in CGPoint(x: point.x + center.x, y: point.y + center.y) } return points.lazy.map(\.cgPoint).map(translatedByCenter) } } extension StarParameters { /// 単位円周上で初期化する初期化子 /// /// - Parameters: /// - vertex: 外側の頂点の数 /// - smoothness: 外接円と内接円の半径の比率 init(vertex: UInt = 5, smoothness: Double = 0.5) { /// 内側も含めた頂点 let offests = (1...(2 * vertex)) /// 中心角 let rotation = (2 * .pi)/Double(offests.count) /// 1つ目の頂点 let start = PolarCoordinate() self.points = offests.reduce(into: [start]) { result, offest in var last = result.last! last.angle += rotation last.radius = (offest % 2 == 1 ? smoothness : 1) result += [last] } } } extension StarParameters { /// 中心座標を設定します func center(x: CGFloat, y: CGFloat) -> Self { var parameters = self parameters.center = CGPoint(x: x, y: y) return parameters } /// 半径を設定します func radius(_ radius: CGFloat) -> Self { var parameters = self parameters.points = points.map { $0.scaled(by: Double(radius)) } return parameters } /// 回転角を設定します func rotated(by angle: CGFloat) -> Self { var parameters = self parameters.points = points.map { $0.rotated(by: Double(angle)) } return parameters } }
cgPoints
によって、平面上の点群を取得できるように実装しています。
たとえば、ある矩形領域内の中央に配置した星型の図形の頂点のリストは次のようにして取得することができます。func starPoints(in rect: CGRect) -> [CGPoint] { return StarParameters(vertex: 5, smoothness: 0.5) // 頂点の数, 滑らかさ .center(x: rect.midX, y: rect.midY) // 内接円の中心 .radius(min(rect.midX, rect.midY)) // 内接円の半径 .rotated(by: -.pi/2) // 回転 .cgPoints }この CGPoint のリスト要素に対して、逐次、直線を引くことによって星型の図形を作図することができます。
ちなみに、これらのデータ構造は CoreGraphics フレームワークにのみ依存した実装なので、SwiftUI だけでなく UIKit でも再利用することができます。UIKit では、UIBezierPath で描画する場合の処理簡易化のために利用すると良いでしょう。
SwiftUI
Shape と Path
図形を SwiftUI のビューとして扱う場合は、
Shape
プロトコルに適合します。Shape
は、与えた矩形に対してPath
を返す実装を要求します。SwiftUIpublic protocol Shape : Animatable, View { func path(in rect: CGRect) -> Path }ここで星型の
Path
を作成することになります。Path
は図形の経路を表現した構造体です。星型のような多角形は、直線で囲まれた閉じた経路として表現されるので、次の手順で作成します。
move(to:)
まず、図形の開始地点の頂点に移動します。
addLine(to:)
次に、次の頂点に向かって、逐次、線を引いていきます。
closeSubpath()
最後に経路を閉じます。
実装例
実際の実装は次の通りです。
StarShape.swiftimport SwiftUI /// 星型のシェイプ struct StarShape: Shape { /// 頂点の数 var vertex: UInt = 5 /// 滑らかさ var smoothness: Double = 0.5 /// 回転角 var rotation: CGFloat = -.pi/2 func path(in rect: CGRect) -> Path { Path { path in let points: [CGPoint] = starPoints(in: rect) path.move(to: points.first!) points.forEach { point in path.addLine(to: point) } path.closeSubpath() } } /// 星型の頂点のリスト /// /// - Parameter rect: 星型の外接円が内接する矩形領域 private func starPoints(in rect: CGRect) -> [CGPoint] { return StarParameters(vertex: vertex, smoothness: smoothness) .center(x: rect.midX, y: rect.midY) .radius(min(rect.midX, rect.midY)) .rotated(by: rotation) .cgPoints } }Shape は View のサブタイプなので、
PreviewProvider
でプレビューすることもできます。#if DEBUG struct StarPath_Previews: PreviewProvider { static var previews: some View { StarShape(vertex: 5, smoothness: 0.5) } } #endifプレビューによって、次のように表示されます。
プレビューでは、Attribute Inspector でプロパティを変更できて、変更ごとに図形が更新されるので面白いです。
内部の塗りつぶし
StarShape を
Shape
として実装したことで、利用する側で自由に図形内部を塗りつぶすことができます。
塗りつぶしには、Shape
のfill
を使用します。StarView.swiftimport SwiftUI /// 星型のビュー struct StarView<Style: ShapeStyle>: View { /// 頂点の数 var vertex: UInt /// 滑らかさ var smoothness: Double /// 塗りつぶし var style: Style var body: some View { StarShape(vertex: vertex, smoothness: smoothness) .fill(style) .aspectRatio(1, contentMode: .fit) } }
PreviewProvider
はGroup
を使って、まとめて表示確認ができて便利です。#if DEBUG struct StarView_Previews: PreviewProvider { static var previews: some View { Group { StarView(vertex: 5, smoothness: 0.5, style: Color(red: 0, green: 1, blue: 1)) StarView(vertex: 6, smoothness: 0.6, style: Color(red: 1, green: 0, blue: 1)) StarView(vertex: 7, smoothness: 0.7, style: LinearGradient(gradient: .init(colors: [.pink, .orange]), startPoint: .init(x: 0.5, y: 0), endPoint: .init(x: 0.5, y: 0.6))) StarView(vertex: 8, smoothness: 0.8, style: RadialGradient(gradient: .init(colors: [.purple, .yellow]), center: .center, startRadius: 0, endRadius: 75)) } .previewLayout(.fixed(width: 160, height: 160)) } } #endif内部と枠線の塗りつぶし
さて、Shape は内部の塗りつぶしだけでなく
stroke
を使って、枠線を塗りつぶす事もできます。StarShape(vertex: vertex, smoothness: smoothness) .stroke(style, lineWidth: 4) .aspectRatio(1.1, contentMode: .fit)しかし、
fill
とstroke
は同時にはできません。fill
やstroke
はShape
を返さずにView
を返します。View
はShape
と異なり、fill
やstroke
メソッドを持っていないため、これが原因でコンパイルエラーになります。StarShape(vertex: vertex, smoothness: smoothness) .fill(style1) .stroke(style2, lineWidth: 4) // コンパイルエラー .aspectRatio(1.1, contentMode: .fit)この場合は、
fill
とstroke
で別の View を作成して、それを重ね合わせて表示させると良さそうです。ZStack
を使って重ね合わせます。struct StarView2: View { /// 頂点の数 var vertex: UInt = 5 /// 滑らかさ var smoothness: Double = 0.5 /// 内部の塗りつぶし var fill: LinearGradient /// 枠線の塗りつぶし var stroke: LinearGradient var body: some View { let shape = StarShape(vertex: vertex, smoothness: smoothness) return ZStack { shape.fill(fill) shape.stroke(stroke, lineWidth: 4) } .aspectRatio(1.1, contentMode: .fit) } }枠線と内部の塗りつぶす
LinearGradient
を適当に設定してプレビューしてみます。
実装はこんな感じです。#if DEBUG struct StarView2_Previews: PreviewProvider { static var previews: some View { Group { StarView2(vertex: 5, smoothness: 0.5, fill: Styles.style1, stroke: Styles.style2) StarView2(vertex: 6, smoothness: 0.6, fill: Styles.style2, stroke: Styles.style3) StarView2(vertex: 7, smoothness: 0.7, fill: Styles.style3, stroke: Styles.style2) StarView2(vertex: 8, smoothness: 0.8, fill: Styles.style4, stroke: Styles.style4) } .previewLayout( PreviewLayout.fixed(width: 160, height: 160) ) } } #endifStyles.swiftimport SwiftUI enum Styles {} extension Styles { static var style1: LinearGradient { LinearGradient( gradient: Gradient(colors: [ Color(red: 239/255, green: 120.0/255, blue: 221/255), Color(red: 239/255, green: 172.0/255, blue: 120/255) ]), startPoint: UnitPoint(x: 0.5, y: 0), endPoint: UnitPoint(x: 0.5, y: 0.6) ) } static var style2: LinearGradient { LinearGradient( gradient: Gradient(colors: [ Color(red: 90/255, green: 177/255, blue: 187/255), Color(red: 247/255, green: 221/255, blue: 114/255) ]), startPoint: UnitPoint(x: 0.5, y: 0), endPoint: UnitPoint(x: 0.5, y: 0.6) ) } static var style3: LinearGradient { LinearGradient( gradient: Gradient(colors: [ Color(red: 14/255, green: 107/255, blue: 168/255), Color(red: 166/255, green: 225/255, blue: 250/255) ]), startPoint: UnitPoint(x: 0.5, y: 0), endPoint: UnitPoint(x: 0.0, y: 0.7) ) } static var style4: LinearGradient { LinearGradient( gradient: Gradient(colors: [ Color(red: 155/255, green: 120/255, blue: 116/255), Color(red: 211/255, green: 62/255, blue: 67/255) ]), startPoint: UnitPoint(x: 0.5, y: 0), endPoint: UnitPoint(x: 0.5, y: 1) ) } } #if DEBUG struct Styles_Previews: PreviewProvider { static var previews: some View { Group { Styles.style1.previewDisplayName("style1") Styles.style2.previewDisplayName("style2") Styles.style3.previewDisplayName("style3") Styles.style4.previewDisplayName("style4") } .previewLayout( PreviewLayout.fixed(width: 100, height: 100) ) } } #endif感想
プレビューしながら実装の内容が確認できるので、快適に実装できるように感じました。
普段はインターフェースビルダーを使うことが多いのですが、慣れてこれば SwiftUI の方が生産性が高くなりそうです。UIKit と異なり、Path が構造体であることも印象が良かったです。
参照渡しだと、経路の変更が外部から容易に可能であるのに対して、値渡しならその心配もなくなるので、今回のようなサンプル実装だけでなく、実際に応用する際にハマりが少なくなりそうだと思いました。iOS 14 からは、WidgetKit の登場によって、SwiftUI の導入が本格化されていくので、引き続き SwiftUI で遊んでみて色々と試していきたいところです。
- 投稿日:2020-07-05T16:28:05+09:00
UserDefaultsに配列を追加・リセットする
端末にデータを保存するにはUserDefaultsを使います。その中でもよく使う配列についてまとめます。
追記:端末にデータを保存することはUserDefaultsの本来の意図ではないようです。ご指摘感謝いたします。データを追加する
データを保存するためにはUserDefaultsからKeyを指定して呼び出す必要があります。
呼び出した後は配列の操作と同じなので、append
やinsert
を用いて追加しましょう。(insert
を使うことで新しいデータが先頭に保存されます。)
呼び出した配列に値を追加した後は、Key(呼び出したものと同じ)を指定してset
します。var userDefaults = UserDefaults.standard var passwordArray = self.userDefaults.array(forKey: "password") as? [String] ?? [] passwordArray.insert(self.roomNameTextField.text!, at: 0) self.userDefaults.set(passwordArra, forKey: "password")データをリセットする
var userDefaults = UserDefaults.standard userDefaults.removeObject(forKey: "password")
- 投稿日:2020-07-05T15:52:59+09:00
初回起動時のみ画面遷移する方法
備忘録。ログインしていない状態の時には登録画面に遷移するなどにも応用できます。
application(_:didFinishLaunchingWithOptions:)にUserDefaultsを設定する
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. let userDefaults = UserDefaults.standard let firstLunchKey = "firstLunchKey" let firstLunch = [firstLunchKey: true] userDefaults.register(defaults: firstLunch) return true }起動画面のViewDidAppeearにUserDefaultsを設定する
override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) let userDefaults = UserDefaults.standard let firstLunchKey = "firstLunchKey" if userDefaults.bool(forKey: firstLunchKey) { performSegue(withIdentifier: "遷移させたい画面へのsegue.identifier", sender: self) } }
- 投稿日:2020-07-05T14:28:10+09:00
[Swift]Gameplaykit Programming Guide超要約
GameplayKit Programming Guide
GameplayKitとはどんなものか
- ゲームを作る際のアーキテクチャ 設計・構築
- ゲームを作る上で必要な複雑なアルゴリズムの構築をサポート
- 他の上位層のゲーム用ライブラリとは独立しており、ゲームを作る際自由に組み合わせて使うことができる。
- 例えば、Gamekitと、2Dゲームの場合はSpriteKit, 3Dゲームの場合はSceneKit, サードパーティ製のエンジンを使いたい場合はMetal や OpenGLESを組み合わせて使える。グラフィックがさほど要らないゲームならUIKitと組み合わせてもいい。
- GameplayKitのカバーする領域
- ランダム化
- 乱数を提供する。
- エンティティ-コンポーネントアーキテクチャ
- ある動作を定義したコンポーネントをオブジェクトに複数つけることで、ゲーム上に多様なオブジェクトを定義するためのアーキテクチャ 。
- ステートマシン
- ゲーム内のオブジェクトがどの状態においてどの動きをするか、またどの状態からどの状態へ移行できるかを簡単に記述するための機能。
- ミニマックス法
- オセロ・将棋などの完全情報ゲームにおいて、コンピュータ(人工知能)の動きを実装するために用いる。
- 経路検索
- ゲーム盤、フィールド上などでオブジェクトを動かす際の経路の検索に関わるライブラリ。
Agent
,Goal
,Behavior
アーキテクチャ
- 自律的にゲーム上のオブジェクトを動かすために、オブジェクトに行動の目的(Goal)を設定するためのアーキテクチャ 。
- ルールシステム
- ゲーム上に設定するルールを規定する。
- 投稿日:2020-07-05T10:45:35+09:00
[iOS]ゲーム開発 初心者の学び
自己紹介
iOSで開発経験が2年程度ありますが、ゲームは全く作ったことがありません。趣味でゲームを作ろうと思っています。前提知識を随時まとめてみます。書いたコードはこちらです Github
アーキテクチャ
Entity-Component Architecture
- ゲームで使うオブジェクト(
Entity
)に、様々な再利用可能な振る舞いを定義した部品(Component
)を持たせる形で作っていく。GameplayKit
の中のGKComponent
を継承したクラスにより、Component
を作成していく。- 自前でこうした仕組みを作ることもできるが、
GameplayKit
を利用していくことで楽にできる。
GKEntity
- 通常は特にサブクラス化せず、
addComponent(component:)
メソッドでコンポーネントを追加していく。update(deltatime:)
:Entity-Component
アーキテクチャ では周期的に行う動作を定義する必要がある。このメソッドを呼ぶと、そのエンティティが持っているコンポーネントのupdate(deltaTime:)
メソッドも全て呼ばれる。deltatime
とは、前回アップデートがされてからの秒数を指す。
GKComponentSystem
- ある種類のコンポネント全てに対しupdateを行いたい時に使用できる。例えば、ゲーム内に存在する全オブジェクトに物理演算を行いたい時など。
let graphicsComponentSystem = GKComponentSystem(componentClass: GraphicsComponent.self) let physicsComponentSystem = GKComponentSystem(componentClass: PhysicsComponent.self) for entity in entities { //entity内に存在するGraphicComponentを取得 graphicsComponentSystem.addComponent(foundIn: entity) //entity内に存在するPhysicsComponentを取得 physicsComponentSystem.addComponent(foundIn: entity) } //全GraphicComponentをupdate graphicsComponentSystem.update(deltaTime: 0.033) //全PhysicsCOmponentをupdate physicsComponentSystem.update(deltaTime: 0.033)
- ゲーム全体の時間経過の管理
- どのオブジェクトで時間経過を管理するかを決める。
ViewController
サブクラスか、SKScene
、GLKViewController
、その他自分で作ったカスタムクラスなど。- そのオブジェクトの中に次のような変数を作る。
- このような時間経過とは、言い換えればゲーム内で直前のフレームからどのくらい時間が経ったかという事を示す。できれば、1秒間に60フレーム(すなわち、経過時間が0.0166秒 = 16ミリ秒程度)になるのが良いが、処理が重い場合は達成できないこともある。
TimeKeeper.swiftpublic class TimeKeeper { public var lastFrameTime: Double = 0.0 public func update(currentTime: Double) { // このメソッドが最後に呼ばれた時からどのくらい時間が経ったか計算 let deltaTime = currentTime - lastFrameTime // 1秒で3単位移動する let movementSpeed = 3.0 // 速度と時間を掛け算し、このフレームでどのくらい移動するか決める someMovingObject.move(distance: movementSpeed * deltaTime) // lastFrameTime に currentTime を代入し、次回このメソッドが呼ばれた際、 // 経過時間を計算できるようにしておく lastFrameTime = currentTime } }
- アプリがアクティブ・非アクティブになるタイミングを知る
- ゲームがポーズできるようにアプリが非アクティブになるタイミングが知りたい時や、逆にアクティブになったタイミングが知りたい場合がある。
NotificationCenter
を用いてそのタイミングを知ることができる。- ターン制のゲームの場合、リアルタイム性がないので、ポーズなどはあまり気にしなくて良くなる。
- アプリがバックグラウンドになった時には、あまりメモリを消費するような動きはせず最小限の動きのみにすること。消費が激しい場合、Appleからリジェクトされる場合がある。
GameViewController.swiftprivate extension GameViewController { override func viewDidLoad() { super.viewDidLoad() setObserver() } func setObserver() { let center = NotificationCenter.default let didBecomeActive = #selector( GameViewController.applicationDidBecomeActive(notification:) ) let willEnterForeground = #selector( GameViewController.applicationWillEnterForeground(notification:) ) let willResignActive = #selector( GameViewController.applicationWillResignActive(notification:) ) let didEnterBackground = #selector( GameViewController.applciationDidEnterBackground(notification:) ) center.addObserver(self, selector: didBecomeActive, name: UIApplication.didBecomeActiveNotification, object: nil) center.addObserver(self, selector: willEnterForeground, name: UIApplication.willEnterForegroundNotification, object: nil) center.addObserver(self, selector: willResignActive, name: UIApplication.willResignActiveNotification, object: nil) center.addObserver(self, selector: didEnterBackground, name: UIApplication.didEnterBackgroundNotification, object: nil) } @objc func applicationDidBecomeActive(notification: Notification) { print("アプリがアクティブになった") } @objc func applciationDidEnterBackground(notification: Notification) { print("アプリがバックグラウンドに入った (テクスチャのアンロードなどを行う)") } @objc func applicationWillEnterForeground(notification: Notification) { print("アプリがアクティブになる (アンロードしたテクスチャの再読み込みなどを行う)") } @objc func applicationWillResignActive(notification: Notification) { print("アプリが非アクティブになる (ゲームのポーズなどを行う)") } }なお上に列挙した関数に限っていうと
- アプリ起動時には
applicationDidBecomeActive
- ホームボタンなどを押してアプリがバックグラウンドに行った時には
applicationWillResignActive
とapplciationDidEnterBackground
- アプリアイコンを押すなどしてアプリが前面に戻った時には
applicationWillEnterForeground
とapplicationDidBecomeActive
が呼ばれる。
このようなアプリのライフサイクルの詳細についてはこちらやこちら(iOS13以降)など参照。
Timer
クラスによるゲームの更新
- 特定の秒数が経過した後(あるいは、経過する毎)に処理をしたいということがある。その場合、
Timer
クラスを使うと良い。GameViewController.swiftvar timer: Timer? = nil override func viewDidLoad() { super.viewDidLoad() // タイマーの設置 timer = Timer.scheduledTimer( timeInterval: 0.5, // 0.5秒後に発火 target: self, selector: #selector(updateWithTimer(timer:)), userInfo: nil, repeats: true // くり返しON ) } @objc func updateWithTimer(timer: Timer) { //タイマー発火時に呼ばれるメソッド。ゲームの更新などを行う。 print("Timer Went Off!") } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) // タイマーを止める timer?.invalidate() timer = nil }なお、
DispatchQueue
を使い以下のように書くこともできる。func placeBomb() { print("bomb placed.") } func explodeBomb() { print("bomb!") } //"bomb placed"の10秒後に"bomb!"と表示したい。 let deadline = DispatchTime.now() + 10 placeBomb() DispatchQueue.main.asyncAfter(deadline: deadline, execute: { // Time's up explodeBomb() })
ゲームのポーズ
- ゲームをポーズした際、一部のオブジェクトは動作を続け、一部のオブジェクトは停止するということが多い。例えば、ネットワークを介して通信する部分や、ユーザインターフェースを担当する部分はポーズで止めないのが普通だと考えられる。
- その場合、Bool値でゲームのポーズを管理するとともに、ゲームオブジェクトをポーズし得るものとし得ないものの2タイプに分ければ良い。
for gameObject in gameObjects { if paused == false || gameObject.canPause == false { gameObject.update(deltaTime: deltaTime) } }
- ゲーム開始からの経過時間
- ゲーム開始時刻、現在時刻の二つを
Date
クラスで持つ。その二つの間の経過時間を、timeIntervalSince
メソッドで計算すればよい。GameViewController.swiftprivate extension GameViewController { /// ゲームの開始時刻 var gameStartDate: Date? override func viewDidLoad() { super.viewDidLoad() gameStartDate = Date() } /// ゲーム開始からの経過時間を取得する func getCurrentElapsedTime() { let now = Date() guard let date = gameStartDate else { return } let timeSinceGameStart = now.timeIntervalSince(date) NSLog("The Game Started \(timeSinceGameStart) seconds ago.") //経過時間(秒数)を、何時間・何分・何秒という形に整形する let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute, .second] formatter.unitsStyle = .positional let formattedString = formatter.string(from: timeSinceGameStart) print("Time elapsed: \(formattedString ?? "")") } }
- 依存性(ある処理が終わってから別の処理をやる)
いろいろやり方はあると思うが、
Operation
クラスのaddDependency(_:)
メソッドが利用できる。let firstOperation = BlockOperation { () -> Void in print("First Operation") } let secondOperation = BlockOperation { () -> Void in print("Second Operation") } let thirdOperation = BlockOperation { () -> Void in print("Third Operation") } //2番目の`Operation`は、1番目と3番目が終わった後のみやるようにする。 secondOperation.addDependency(firstOperation) secondOperation.addDependency(thirdOperation) let operations = [firstOperation, secondOperation, thirdOperation] //バックグラウンド にて実行開始 let backgroundQueue = OperationQueue() backgroundQueue.qualityOfService = .background backgroundQueue.addOperations(operations, waitUntilFinished: true)こうすると、下記のように依存性を設定した2番目の
Operation
が最後に実行される。Third Operation First Operation Second Operation
- 画像などのアセットをゲーム実行中に読み込みたい
- 先の
Operation
クラスで、バックグラウンドで実行するよう指定すれば良い。- 画像の全読み込みが終わってからやりたい処理(画面を更新するなど)があれば、依存性を設定すればよい。
let imagesToLoad = ["image1.jpg", "image2.jpg", "image3.jpg"] let imageLoadingQueue = OperationQueue() // バックグランドで実行するよう設定する imageLoadingQueue.qualityOfService = .background // 複数の画像の同時読み込みを許可する(10個まで) imageLoadingQueue.maxConcurrentOperationCount = 10 // 全画像読み込み完了後にやりたい処理 let loadingComplete = BlockOperation { () -> Void in print("loading complete!") } var loadingOperations: [Operation] = [] for imageName in imagesToLoad { let loadOperation = BlockOperation { () -> Void in print("Loading \(imageName)") } loadingOperations.append(loadOperation) // 最後にやりたい処理に対して、依存性を設定していく。 loadingComplete.addDependency(loadOperation) } imageLoadingQueue.addOperations(loadingOperations, waitUntilFinished: false) imageLoadingQueue.addOperation(loadingComplete)表示関係
SpriteKit
2Dグラフィックでのアニメーションを行うための公式ライブラリ。
SKView
UIViewのサブクラス。
presentScene(_:)
:シーン(SKScene
クラスオブジェクト)を呼び出すメソッド。シーンがビュー上に呼び出されると、シミュレーションしているだけの状態から実際に画面上にそれを表示させる。isPaused
: このプロパティをtrueにすると、SKView上のシーンが一時停止する。参考
- "iOS Swift Game Development Cookbook: Simple Solutions for Game Development Problems" 3rd Edition, Jonathon Manning & Paris Buttfield-Addison, O'Reilly Media, 2018
- GameplayKit Programming Guide
- 投稿日:2020-07-05T09:48:13+09:00
【Swift】ズボラな通知音変更
こんにちは。 いるべさんです。
Firebaseを使ってユーザ間の通知を行う実装をしていたとき、馴染みのある「ティロリン♪」ってやつしか使い方分からなくて困っていた。
通知音をインポートして再生するやり方はよく見かけた。
が、めんどくさがり屋ないるべさんはそんなことしたく無い!!!今回はAudioToolbox使って通知音とする様にした。
ただ、こんなことしなくてもちゃんとしたやり方がありそう。構成仕様
構成としては、ユーザがCloud FireStoreにデータを突っ込んだらそれをClound Functionsが検知して購読しているユーザに通知を送るよくある普通の実装。
通知を受け取ったら通知情報からsoundの項目を引っ張ってきてAudioToolboxに再生してもらう簡単仕様。
なので、通知を送るFunctionsのpayloadのsoundの項目にSoundSystemIdの値、もしくは通知ごと、ユーザごとに通知音を変えたいならその識別子を入れておく。
実装
Functions
まずはCloud Functionsの関数を作成する。大事そうなところだけピックアップして記載しておく。
今回の話の肝になるのはpayloadのsound。
ここにAudioToolboxに通用するSystemSoundIdを入れておく。index.jsexports.sendNotifications = functions.firestore.document('/notifications/{userId}/messages/{messageId}').onCreate((snapshot, context) => { ~~ 省略 ~~ var payload = { notification: { title: data.username, body: data.detail, badge: "1", sound: "1020" } }; ~~ 省略 ~~ return admin.messaging().sendToTopic(topic, payload, options) .then(function(response) { return console.log(response); }) .catch(function(error) { return console.log(error); });通知を受け取る側
まぁ何も難しいことはしてなくて、通知から必要な情報取り出してAudioToolboxに突っ込んでいるだけ。
普通にやる場合はcompletionHandlerに.soundを入れる形なのでそのやり方で今回と同じ様なことができたらなぁと思う。ご存知の方、ぜひ教えてください!SceneDelegate.swiftfunc userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { let userInfo = notification.request.content.userInfo if let aps = userInfo["aps"] as? [String: Any] { let soundId: SystemSoundID = SystemSoundID(aps["sound"] as! String) ?? 1007 AudioServicesPlaySystemSound(soundId) } completionHandler([.alert, .badge]) }これでユーザが通知を受け取ったとき、今回で言えば設定したid:1020が鳴るというわけです。
やり方が正しいかは分からないけど思い通りの実装ができたので今のところはヨシ!!!お読み頂き、ありがとうございました。
- 投稿日:2020-07-05T09:33:12+09:00
Realm マイグレーションについて
- 投稿日:2020-07-05T09:33:12+09:00
Realm Swiftに関してのポイントまとめ
ある種類のオブジェクトを全て削除したい
// Delete all objects from the realm try! realm.write { realm.deleteAll() }・・・これだと、Realm上にある全データを削除してしまう。
特定の種類を全削除するには、まずのその種類のオブジェクトを全て読み込んでから、これを
delete()
メソッドに渡せば良い。let realm = try! Realm() let objects = realm.objects(HogeHoge.self) try! realm.write { realm.delete(objects) }参考: Can we delete all objects of particular type #4769
マイグレーション
Realmに保存しているモデルに変更があった際には、
Migration is required due to the following errors
などのエラーが発生する。モデルに変更があると、コード上で変更されたモデルと、すでにディスクにある変更前のモデルで整合性が取れなくなるためである。
下記ページのようにマイグレーションを行うことになる。
他方、開発段階ではモデルの変更があるのはよくあることなので、一々マイグレーションを書くのではなく、ディスク上のデータを一回削除してから、コード上の新しいモデルを試せばよいだろう。
- 投稿日:2020-07-05T09:02:00+09:00
Swift PKCanvasView、データ取得/読込
データ取得
@IBOutlet weak var pKCanvasView: PKCanvasView!let data: Data = self.pKCanvasView.drawing.dataRepresentation()Data型を取得出来ます。
取得後、UserDefaults、CoreData等に保存出来ます。データ反映
do { self.pKCanvasView.drawing = try PKDrawing(data: page) } catch { let nserror = error as NSError fatalError("Unresolved error \(nserror), \(nserror.userInfo)") }保存したData型を使用してPKDrawingを生成、設定します。
- 投稿日:2020-07-05T08:44:47+09:00
Composable Architecture を利用した Todo アプリの紹介(Part 1)
はじめに
こちらの記事を拝見して、SwiftUI なら Composable Architecture で書くのが良さそうと思い、SwiftUI・Composable Architecture の勉強がてら書くことにしました。
基本的には Composable Architecture の作者さんが公開されている A Tour of the Composable Architecture の Part 1 - 4 を実際に追ってみたので、それをまとめようかと思います。A Tour of the Composable Architecture は作者である POINT-FREE さんの100個目の動画にあたるらしく、それより前の動画で Composable Architecture に関わる内容についても言及されているようです。(まだ見ることができていませんが...)Composable Architecture とは?
Composable Architecture 自体の説明については、 ↑の yimajo さんの記事にまとまっているので、本記事では省略しようと思います。触ってみた感想としては、Redux のような感じで、SwiftUI とも相性が良さそうで、実際に使ってアプリを作ってみたいという印象でした。また、Composable Architecture が提供してくれるテストをサポートする機能によって、テストも非常に楽に書けそうで、今後このライブラリを利用して実践的なアプリも作ってみたいと思えました。
Composable Architecture を利用した Todo アプリ (Part 1)
一気に Part 1 - Part 4 の内容を書こうとすると挫けそうなので、一旦 Part 1をかいつまんで行こうと思います。
まずは、Xcode で SwiftUI プロジェクトを作成し、SPM で ComposableArchitecture を導入しましょう。SPM による導入方法などは省略します。
作成できたら、
ContentView.Swift
に、空ですが以下の三つを書いていきます。それぞれ Composable Architecture で必要になってくるものです。上から順にざっくりと説明すると、State
は名前の通りアプリで扱うことになる状態で、基本的には構造体で定義すると思いますが、必ずしも構造体である必要はないと作者は言っています。Action
はボタンのタップ、テキストフィールドへのテキスト入力など、ユーザが実行するアクションを定義する場所になっています。こちらは基本的には列挙型を使います。最後にEnvironment
は API Client、UUID、Scheduler など宣言する度に値が変わったり、外部から値を差し込んだほうが好都合なもの(テスト時などのことを考えた場合など)を定義する場所になっています。こちらは基本的には構造体を使います。ContentView.swiftstruct AppState { } enum AppAction { } struct AppEnvironment { }次に
State
、Action
、Environment
をまとめて使用し、アプリケーションのビジネスロジックを担当することになるReducer
を定義します。基本的には、Action
ごとにロジックを分岐していくので、switch
を使用してaction
ごとの処理を書いていくことになります。ContentView.swiftlet appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in switch action { } }もう一つ、Composable Architecture には
Effect
という重要な要素があります。Effect
は、API リクエストの実行、ディスクへのデータ書き込みなど、外部との通信を行ったり...という部分になり、Reducer
の中でEffect
を返却することができますが、今回はEffect
を使用しないので、このくらいの説明に留めることにしようと思います。
Reducer
によってビジネスロジックを表現することができますが、Reducer
で変更したState
をViewから利用するためには、Store
を利用します。一旦、Store
を持った簡単なViewを定義することにしましょう。ContentView.swiftstruct ContentView: View { let store: Store<AppState, AppAction> var body: some View { NavigationView { List { Text("Hello") } .navigationBarTitle("Todos") } } }
Store
を初期化できる部分は、(プロジェクト作成時の)SwiftUIではSceneDelegate.swift
とContentView.swift
内のContentView_Previews
になるので、両方からStore
の初期化を行います。初期化には先ほど定義したappReducer
、AppState()
、AppEnvironment()
を使用します。SceneDelegate.swiftlet contentView = ContentView( store: Store( initialState: AppState(), reducer: appReducer, environment: AppEnvironment() ) )ContentView.swiftstruct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView( store: Store( initialState: AppState(), reducer: appReducer, environment: AppEnvironment() ) ) } }基本的な部分は整ってきたので、空で定義してしまっていた部分などを徐々に埋めていきます。Todo アプリを作るので、まずは Todo のモデルを定義します。単純な Todo の文章と、実行済みか否かのフラグを持つだけのモデルです。
ContentView.swiftstruct Todo { var description = "" var isComplete = false }モデルを定義したので、当然
State
はこの Todo を保持することになります。追加していきましょう。ContentView.swiftstruct AppState { var todos: [Todo] = [] }ここまで出来上がったので、まだ todos の中身は空っぽですが、View から
Store
にアクセスして、todo を表示するようなプログラムを書いてみます。todos は空っぽなので、Hello
というテキストを表示するようにしています。ContentView.swiftstruct ContentView: View { let store: Store<AppState, AppAction> var body: some View { NavigationView { List { ForEach(self.store.state.todos) { todo in Text("Hello") } Text("Hello") } .navigationBarTitle("Todos") } } }これで上手くいくかなと思いきや、
Store
からState
に直接アクセスすることは禁止されているので、↑のようなことはできません。アクセスするためには、ViewStore
というものを使用することになります。
ViewStore
を使用するメリットについては、公式の Part 1 の記事に詳しく載っていますが、簡単に説明するとViewの再計算のコストを抑え、パフォーマンス的にも向上し、iOS/macOS などの異なるプラットフォームで共通のロジックを使うことができるらしいです。
実際にViewStore
を用いたコードを書く前に、事前に定義していたTodo
とAppState
をEquatable
プロトコルに適合させます。理由としては、ViewStore
がState
を排出する際に重複を自動的に取り除けるようにするためです。(ViewStore
のコードを見てみると、State
の重複を取り除くための仕組みが提供されていることがわかります。)ContentView.swiftstruct Todo: Equatable { var description = "" var isComplete = false } struct AppState: Equatable { var todos: [Todo] } var body: some View { NavigationView { WithViewStore(self.store) { viewStore in List { ForEach(viewStore.state.todos) { todo in Text("Hello") } Text("Hello") } .navigationBarTitle("Todos") } } }しれっと、ついでに
ViewStore
を用いたコードも書いてしまいました。このコードの中では、viewStore.state.todos
という形でState
にアクセスしていますが、これは Swift の Dynamic Member Lookup を利用していて、あたかもViewStore
上に、直接State
プロパティが存在するかのようにアクセスすることができるようになっています。しかし、まだアプリを動かすことはできないので、少し手を加えていきます。
ForEach
メソッドで各 Todo の要素を一意に識別することができるように Todo に id を持たせて以下のように変更していきます。ForEach
については本質ではないので、細かい説明は省くことにします。ContentView.swiftstruct Todo: Equatable, Identifiable { let id: UUID var isComplete = false var description = "" }上記のように定義することによって、
ForEach
から各 Todo を適切に扱うことができるようになります。アプリを動かすために、Store
の初期化時にいくつかの todo を渡すように変更します。以下のように変更することで、一旦アプリは動くようになります。ContentView.swiftstruct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView( store: Store( initialState: AppState( todos: [ Todo( description: "Milk", id: UUID(), isComplete: false ), Todo( description: "Eggs", id: UUID(), isComplete: false ), Todo( description: "Hand Soap", id: UUID(), isComplete: false ), ] ), reducer: appReducer, environment: AppEnvironment() ) ) } }徐々にアプリに機能を追加していきます。
既に View から 各 todo にアクセスすることができるようになっているので、簡単なチェックボックスと TextField を追加します。todo のisComplete
によってチェックが付くチェックボックスと todo のdescription
を表示する TextFiled になっています。ContentView.swiftvar body: some View { NavigationView { WithViewStore(self.store) { viewStore in List { ForEach(viewStore.state.todos) { todo in HStack { Button(action: {}) { Image(systemName: todo.isComplete ? "checkmark.square" : "square") } .buttonStyle(PlainButtonStyle()) .foregroundColor(todo.isComplete ? .gray : nil) TextField( "Untitled todo", text: .constant(todo.description) ) } } Text("Hello") } .navigationBarTitle("Todos") } } }見た目はできてきたので、
Action
を定義していきます。具体的にはチェックボックスのタップイベントと TextField を編集できるようなAction
を定義します。ContentView.swiftenum AppAction { case todoCheckboxTapped case todoTextFieldChanged(String) }ただ、↑のように定義してしまうと、todos の中のどの todo に対するアクションなのかがはっきりしないので、少し手を加えます。
ContentView.swiftenum AppAction { case todoCheckboxTapped(index: Int) case todoTextFieldChanged(index: Int, text: String) }後は、Reducer で具体的なビジネスロジックを実装していくことになります。イメージだけ先に示すと↓のような感じです。
ContentView.swiftlet appReducer = Reducer<AppState, AppAction, Void> { state, action, _ in switch action { case .todoCheckboxTapped(index: let index): // ここに State を変更するための具体的なロジックを実装していく case .todoTextFieldChanged(index: let index, text: let text): // ここに State を変更するための具体的なロジックを実装していく } }
.todoCheckboxTapped(index: )
の中では、state.todos[index].isComplete.toggle()
のように単純にisComplete
を変更するだけで良さそうです。.todoTextFieldChanged(index:, text: )
の方でも単純にtext
をdescription
に入れてあげるだけになります。それらを踏まえた実装は以下のようになります。ContentView.swiftlet appReducer = Reducer<AppState, AppAction, Void> { state, action, _ in switch action { case .todoCheckboxTapped(index: let index): state.todos[index].isComplete.toggle() return .none case .todoTextFieldChanged(index: let index, text: let text): state.todos[index].description = text return .none } }勝手に謎の
return .none
という記述を追加してしまいました。これは前の方でほとんど説明しなかったのですが、Reducer
はEffect
を返すことができるので、Effect
を何も返さない場合は明示的にreturn .none
とする必要があります。
一旦これで基本的なビジネスロジックは完成しました。後はビジネスロジックを View から利用するだけになります。
View から State を変更する方法は、ViewStore
を介してAction
を送ってあげれば良いです。
チェックボックスをタップした時には.todoCheckboxTapped
アクションを送れば良いので、Button(action: { viewStore.send(.todoCheckboxTapped(index: index)) })
のように書くことができます。TextField の方は若干特殊で、TextField にテキストが入力された場合、
.todoTextFieldChanged
アクションを送るのと同時に、TextField には todo の中にあるdescription
を表示する必要があります。
ViewStore
にはこのような場合のためにヘルパーメソッドが用意されているので、以下のように簡単に実現することができます。ContentView.swiftTextField( "Untitled Todo", text: viewStore.binding( get: { $0.todos[index].description }, send: { .todoTextFieldChanged(index: index, text: $0) } ) )以上を実装した全体像は以下のようになります。
ContentView.swiftvar body: some View { NavigationView { WithViewStore(self.store) { viewStore in List { ForEach(viewStore.state.todos) { todo in HStack { Button(action: { viewStore.send(.todoCheckboxTapped(index: index)) }) { Image(systemName: todo.isComplete ? "checkmark.square" : "square") } .buttonStyle(PlainButtonStyle()) .foregroundColor(todo.isComplete ? .gray : nil) TextField( "Untitled todo", text: viewStore.binding( get: { $0.todos[index].description }, send: { .todoTextFieldChanged(index: index, text: $0) } ) ) } } Text("Hello") } .navigationBarTitle("Todos") } } }これで基本的な実装は一通り終わりです。
ただ、本当にState
が更新されているかを確かめるための手段も Composable Architecture には備わっていて、それも記事の最後で紹介されているので、説明します。方法は簡単で、
Reducer
の debug() メソッドを使用するだけで良いです。例えば、SceneDelegate.swiftlet contentView = ContentView( store: Store( initialState: AppState( todos: [ Todo(id: UUID()), Todo(id: UUID()), ] ), reducer: appReducer.debug(), environment: () ) )こんな感じで、
debug()
を付ければ実現できますし、以下のように特定のReducer
だけに付けることもできます。ContentView.swiftlet appReducer = Reducer<AppState, AppAction, Void> { state, action, _ in ... } .debug()実際に
debug()
を付けたまま実行してチェックボックスをタップすると、Debug Console に以下のような表示がされます。received action: AppAction.todoCheckboxTapped( index: 0 ) AppState( todos: [ Todo( − isComplete: false, + isComplete: true, description: "Milk", id: 5834811A-83B4-4E5E-BCD3-8A38F6BDCA90 ), Todo( isComplete: false, description: "Eggs", id: AB3C7921-8262-4412-AA93-9DC5575C1107 ), Todo( isComplete: true, description: "Hand Soap", id: 06E94D88-D726-42EF-BA8B-7B4478179D19 ), ] )チェックボックスをタップしたことによって、最初の Todo の
isComplete
プロパティだけがfalse
からtrue
に変化していることが簡単にわかります。もちろん、description
も同じように調べることができますが、実際に試してみて頂けると良いと思います。おわりに
まとめると言いながら、元の記事が良くまとめられすぎていて、ただの翻訳のようになってしまった気がします...
Composable Architecture には、あと3つの Part2 - Part4 の記事があり、Composable Architecture のパワフルなテストサポート機能を使ったテストを書いたり、あまり説明しなかった Effect を使ったり、より実践的な Composable Architecture について知ることができます。GitHub のリポジトリにもいくつかサンプルがあったので、そちらも覗いてみると参考になりそうです。
自分も Part4 まで一通り読みながら写経してみて、Composable Architecture すごい!と思い続けていたので、元の記事を参照していただけると、もっと Composable Architecture について知ることができるので、ぜひ読んでみてもらえると良いと思います!参考文献
- GitHub / swift-composable-architecture
- POINT-FREE / A Tour of the Composable Architecture: Part 1
- Qiita(yimajo さん) / Swiftによるアプリ開発のためのComposable Architectureがすごく良いので紹介したい
※本記事は、 POINT-FREE さんが公開している A Tour of the Composable Architecure をもとに許可を得て作成しています。