- 投稿日: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-05T19:39:45+09:00
iOSの webview で"ほぼ"絶対にSafariで開くnpm package "go-to-safari"
iOS 上の WebView から Safari で開く
iOS の Facebook や Messager アプリで開いたページのリンクを Safari で開くことができるようになる npm package を公開しました。
以下のようにすると iOS 上の WebView から Safari で指定した URL を開くことができます。
<a href="https://example.com/" class="outer-link">link</a> <script src="https://unpkg.com/go-to-safari@1.0.2/lib/g2s.js"></script> <script> g2s(".outer-link"); // querySelector で対象のリンクを指定 </script>sample
https://youheinakagawa.github.io/go-to-safari-js/
npm package
参考にしたページ
How open link in safari mobile app from webview
https://stackoverflow.com/a/53028249どういうときに使うのか?
Facebook アプリなどでページを開いたときにアプリの制限で使えない機能があります。
とくに AR Quick Look が使えないため、Facebook で AR Quick Look が埋め込まれているページを開いても AR の体験ができない現象が発生します。Facebook という共有の場で体験ができないという機会損失をできる限り少なくするために開発しました。
Stack Overflow にあるように ftp:// スキーマーなら Safari が開くという仕組みになります。
自前で ftp サーバーを立てているため不安定な時もあるかもしれません。
npm の package を作ることも公開することも初めてなので至らぬ点が多々あるかと思います。
UA で判定しているため Safari と全く同一の UA で WebView を設定されていると開くことはできません。
それでもよろしければ是非お使いください。
- 投稿日:2020-07-05T18:57:44+09:00
imageViewをタップできるようにしょう!
- 投稿日:2020-07-05T18:57:44+09:00
imageViewをタップする
- 投稿日:2020-07-05T16:48:13+09:00
Firebase Crashlyticsを効果的に使うお話
最初に
今回はユーザーさんからのクラッシュの問い合わせをCrashlyticsを使い
どうしたら素早くやバグを解決できるか考えてみました。
デバッグって大変ですよね。導入自体については、公式のドキュメントをご確認ください。
https://firebase.google.com/docs/crashlytics?hl=ja今回は、Android javaでのコードを記載しました。
ユーザーID検索
ユーザーがログインした時などにユーザーの情報をセットしていくと、文字列をいれて送ることができます。
Crashlytics.setUserIdentifier("userid-12345678910"); Crashlytics.setUserEmail("userid-email"); Crashlytics.setUserName("userid-name");そうするとこれはクラッシュ詳細の「データ」のタブに表示されます。
この画像の右下あたりのユーザーの部分です。
実はこの値TOPの右下の「ユーザー検索」から検索ができて検索してみると
Email,Name,Identiferどれも横断で部分一致で検索してくれます。
どのユーザーがどのクラッシュをしたかが特定できます。
ログとキー
これを使うことにより、クラッシュする前にどの画面を経由したか?が分かります。
ログ
// 各ActivityやFragmentでそれぞれ文字列を決めて、logにセットしていきます。 Crashlytics.log("******");セットした値は、最終的にクラッシュしたクラッシュ詳細の「ログ」のタブに上から時系列順に並びます。
例えば以下は、1-aはAのActivity、2-bはBのActivityでCrashlytics.logを呼んでBでクラッシュさせたものです。
そのため、どこの画面から遷移してクラッシュしたか?
クラッシュする前にどの画面を通ったか?などを確認することができます。キー
Crashlytics.setInt("1-a", 1); Crashlytics.setBool("1-a", true); Crashlytics.setDouble("1-a", 1); Crashlytics.setFloat("1-a", 1); Crashlytics.setLong("1-a", 1); Crashlytics.setString("1-a", "1-a");key/valueでデータをセットして送る事もできます。
クラッシュ詳細の「鍵」のタブに集計されます。(翻訳ミスってる説)
こちらの上から順に並んでいきます。
そして便利なのが「キー」をフィルタしますの部分に文字入力すると、そのキーのみの一覧表示ができます。
このログとキーについては、よく考えて設計すると、デバッグにかなり効果を発揮しそうです。非重大
想定されるExceptionが起こった時、それをそのまま渡して集計ができます。
「クラッシュ」ではなく「非重大」の方にまとまります。Crashlytics.logException(Exception); // 自分でExceptionのインスタンスを作って渡す事もできます。 Crashlytics.logException( new IllegalArgumentException("IllegalArgumentExceptionのテストです") );こっちは、左上の「フィルタ」をクリックして非重大に切り替えて確認しましょう。
こちら上に記載したユーザー情報を入れる、ログやキーを入れる
をしておくと非重大の方の詳細にも同じように反映されます。
- 投稿日: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-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: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-05T05:48:39+09:00
iOS14でのIDFA取得
iOS14からユーザトラッキングなどに用いられているIDFA取得のオプトアウトの選択がアプリごとに強制されます。この記事ではiOS14でどのように挙動が変わるかを記載しています。
(2020/6/22公開のXcode12beta/iOS14betaによる調査です。)
(追記:Xcode11とiOS13.4.1でインストールした後、iOS14betaにアップデートしても同じ挙動でした。)ダイジェスト
- iOS14ではトラッキングID取得前のユーザ確認ダイアログの実装が必須化されます
- [重要] 対応していないアプリでも挙動が変わり、IDFAが取得できなくなります
IDFAとは
IDFAはIdentifier For Advertisingの略で、広告目的で用いられることを意図したiOSの端末固有のIDです。固有IDを用いることによって広告プラットフォームがユーザを特定し、ターゲット広告を表示できます。一般的にはトラッキングIDと呼ばれます。Androidでは同等のものとしてADID (Google Play Services ID for Android) が提供されています。
トラッキングIDはターゲット広告の他に、アプリ広告の効果計測や、再インストール(リセマラ)の検出などにも用いられています。もともとトラッキングIDとしては、MACアドレスやスマートフォンのデバイスID(IMEI等)、UDIDなどのありとあらゆる識別子をデベロッパーが好き勝手に使っていましたが、AppleとGoogleはスマートフォンの広告プラットフォームが第三者に奪われることを嫌って、個人情報保護を名目にiOS/Androidのシステムが提供することになりました。
従来のIDFAのオプトアウト
iOS5でIDFAが導入されると同時にトラッキングIDをユーザがオプトアウト(選択的に除外)できる手段が提供されました。選択はiOSの設定アプリの以下ので順で行います。
設定>プライバシー>広告(旧称:アドバタイズ)>追跡型広告を制限(旧称:Ad Trackingを制限)
デフォルトはOFF(制限しない)になっています。設定の奥の方にあるのでたいていのユーザは気づかずにデフォルトのままになっていると思われます。
実際のIDFAにアクセスするためには AdSupport.framework を追加する必要があります。コードを下に示します。
ASIdentifierManager *identifierManager = [ASIdentifierManager sharedManager]; if ([identifierManager isAdvertisingTrackingEnabled]) { NSLog(@"[idfa] isAdvertisingTrackingEnabled enabled"); NSString *idfa = identifierManager.advertisingIdentifier.UUIDString; NSLog(@"[idfa] %@", idfa); } else { NSLog(@"[idfa] isAdvertisingTrackingEnabled disabled"); }isAdvertisingTrackingEnabled が、上の設定アプリでのオプトアウトと連動しています。
iOS14でのIDFA
WWDC2020で、iOS14でトラッキングIDの扱いに変更が加えられることが発表されました。
iOS14以降はIDFAを取得する前にトラッキングID取得の可否をユーザに選択させるダイアログを表示することが義務付けられます。https://developer.apple.com/app-store/user-privacy-and-data-use/
実際には、iOS14で新規追加された AppTrackingTransparency フレームワークを用いて、(おそらくはアプリ起動の最初期に)ダイアログを表示します。ユーザに許可を求めるダイアログの文言はInfo.plistに記述します。Info.plistに記述するということは後から変更することができず、かつ、Appleのレビュー対象となります。推測ですが、同意しないと進めない等の強制的な文言はレビューでNGになると思われます。
Info.plistの文言は多言語化することができますが、ひと手間かかりますので対応が必要です。多くのユーザにとっては即座に理解できない許可を求められても許諾するとは思われないので、IDFA(トラッキングID)の取得は今後困難になると思われます。
日本においては地上波テレビの影響力が大きく、アプリのインストールはテレビCMなど広告経由によるものが多いです。そのためアプリ開発会社は広告会社(電通など)に多額の広告費を支払います。支払う金額の根拠としてトラッキングIDなどを用いて広告費の対費用効果(コスパ)を計測するのですが、トラッキングができなくなると、広告会社(電通など)になんとなくジャブジャブお金を支払っていた昭和の時代に逆戻りする可能性がありますね。
Googleが収益の70%を広告から得ている一方で、AppleはiAd大失敗しました。そうしたことも影響しているのかもしれません。今回Appleは新しいSKAdNetworkに誘導しようとしています。味わい深いですね。参考:SKAdNetwork
閑話休題
iOS14betaでの実際の挙動
Xcode12betaと、iOS13.4.1実機/iOS14.0beta実機の組み合わせ確認しました。
グローバル設定の「追跡型広告を制限」はOFF(追跡可能)になっています。iOS Deployment Target = 13.4の場合
iOS実機 isAdvertisingTrackingEnabled advertisingIdentifier.UUIDString iOS13.4.1 true 実際のIDFA iOS14.0beta false 00000000-0000-0000-0000-000000000000 iOS Deployment Target = 14.0の場合
iOS実機 ユーザ許諾 isAdvertisingTrackingEnabled advertisingIdentifier.UUIDString iOS13.4.1 N/A false 00000000-0000-0000-0000-000000000000 iOS14.0beta NG false 00000000-0000-0000-0000-000000000000 iOS14.0beta OK false 実際のIDFA ユーザ許諾とは requestTrackingAuthorizationWithCompletionHandler を用いて、ユーザにトラッキングID取得の許諾を請うダイアログを表示してその選択結果をさします。
UUIDStringはユーザ許諾の結果の可否を問わずに取得を試みています。つまりどういうこと?
iOS14ではグローバル設定や許諾ダイアログの可否に関わらず、常にisAdvertisingTrackingEnabledはfalseです。つまり、従来コードもグローバル設定で「追跡型広告を制限」がONにされた場合のコードパスを常に通ることになります。
また、iOS Deployment Targetを13以前にした場合も同じ挙動です。isAdvertisingTrackingEnabledはiOS14でdeprecated指定されましたが、それと同時に挙動が変更になっており、異例の対応と言えます。iOS14ではIDFAが取得できるのは、グローバル設定で「追跡型広告を制限」がOFF、かつ、許諾ダイアログの結果がOK、のときのみです。
iOS14でのグローバル設定
設定アプリでのトラッキングID取得に関わるグローバル設定の方式が変わりました。
設定>プライバシー>Tracking>Appからのトラッキングを許可
従来とは意味が逆になっています。
また、アプリごとにON/OFFができるようになりました。(おそらく許諾ダイアログを表示したことがあるアプリのみ)requestTrackingAuthorizationWithCompletionHandlerの挙動
- ATTrackingManagerのrequestTrackingAuthorizationWithCompletionHandlerは初回呼び出しでのみダイアログが表示され、ユーザ選択の結果はシステムに保存されます
- 2回目以降の呼び出しではユーザダイアログは表示されず、前回の回答結果が即座にcallbackに渡されます
- アプリをアンインストール/再インストールすることで再度ダイアログが表示されます
- グローバル設定の"Allow Apps to Request to Track"がOFFのときは、初回呼び出しでもダイアログは表示されず、ユーザ許諾NGの動作をします
検証コード
Xcode12betaで新しくシンプルなプロジェクトを作成して、ViewController.mに手を入れます。
実際に利用すためにはiOS Deployment TargetをiOS14にして、 AdSupport.framework と AppTrackingTransparency.framework を追加する必要があります。ViewController.m#import "ViewController.h" #import <AdSupport/ASIdentifierManager.h> #import <AppTrackingTransparency/ATTrackingManager.h> @interface ViewController () @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; // ユーザ許諾前に一度IDFAの取得を試みます ASIdentifierManager *identifierManager = [ASIdentifierManager sharedManager]; if ([identifierManager isAdvertisingTrackingEnabled]) { NSLog(@"[idfa] isAdvertisingTrackingEnabled enabled"); } else { NSLog(@"[idfa] isAdvertisingTrackingEnabled disabled"); } NSString *idfa = identifierManager.advertisingIdentifier.UUIDString; NSLog(@"[idfa] %@", idfa); [ATTrackingManager requestTrackingAuthorizationWithCompletionHandler:^(ATTrackingManagerAuthorizationStatus status) { NSLog(@"in requestTrackingAuthorizationWithCompletionHandler"); if (status == ATTrackingManagerAuthorizationStatusAuthorized) { NSLog(@"[requestTrackingAuthorization] authorized"); } else if (status == ATTrackingManagerAuthorizationStatusDenied) { NSLog(@"[requestTrackingAuthorization] denied"); } else { NSLog(@"[requestTrackingAuthorization] something else"); } // ユーザ許諾後にIDFAの取得を試みます if ([identifierManager isAdvertisingTrackingEnabled]) { NSLog(@"[idfa] isAdvertisingTrackingEnabled enabled"); } else { NSLog(@"[idfa] isAdvertisingTrackingEnabled disabled"); } NSString *idfa = identifierManager.advertisingIdentifier.UUIDString; NSLog(@"[idfa] %@", idfa); }]; } @end