20200705のiOSに関する記事は11件です。

[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
    }
}

終わりに

いかがでしたでしょうか? 参考になれば幸いです!
ご意見や改善点などあれば、どしどしコメントください!

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

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

go-to-safari

参考にしたページ

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 を設定されていると開くことはできません。
それでもよろしければ是非お使いください。

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

imageViewをタップできるようにしょう!

profileでイメージ変更をしたい時など使用する、imageViewにtapアクションを持たせる

スクリーンショット 2020-07-05 18.35.08.png

xcodeの右上の「+」から TapGesture Recognizer を選択し、imageViewの上にドロップ

次にInteractionのUser Interacton Enabledにチェックを入れる
(僕はこれを忘れてて少しの間頭を抱えていました。。)

スクリーンショット 2020-07-05 18.32.56.png

以上でimageViewをタップできるようになりました!

ここからカメラやアルバムに飛んで写真を表示させます!

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

imageViewをタップする

profileでイメージ変更をしたい時など使用する、imageViewにtapアクションを持たせる

スクリーンショット 2020-07-05 18.35.08.png

xcodeの右上の「+」から TapGesture Recognizer を選択し、imageViewの上にドロップ

次にInteractionのUser Interacton Enabledにチェックを入れる
(僕はこれを忘れてて少しの間頭を抱えていました。。)

スクリーンショット 2020-07-05 18.32.56.png

以上でimageViewをタップできるようになりました!

ここからカメラやアルバムに飛んで写真を表示させます!

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

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");

そうするとこれはクラッシュ詳細の「データ」のタブに表示されます。
この画像の右下あたりのユーザーの部分です。
スクリーンショット 2020-07-05 16.04.35.png

実はこの値TOPの右下の「ユーザー検索」から検索ができて検索してみると
Email,Name,Identiferどれも横断で部分一致で検索してくれます。
スクリーンショット 2020-07-05 16.06.28.png

どのユーザーがどのクラッシュをしたかが特定できます。

ログとキー

これを使うことにより、クラッシュする前にどの画面を経由したか?が分かります。

ログ

// 各ActivityやFragmentでそれぞれ文字列を決めて、logにセットしていきます。
Crashlytics.log("******");

セットした値は、最終的にクラッシュしたクラッシュ詳細の「ログ」のタブに上から時系列順に並びます。
例えば以下は、1-aはAのActivity、2-bはBのActivityでCrashlytics.logを呼んでBでクラッシュさせたものです。
スクリーンショット 2020-07-05 16.15.50.png

そのため、どこの画面から遷移してクラッシュしたか?
クラッシュする前にどの画面を通ったか?などを確認することができます。

キー

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でデータをセットして送る事もできます。
クラッシュ詳細の「鍵」のタブに集計されます。(翻訳ミスってる説)
スクリーンショット 2020-07-05 16.24.22.png

こちらの上から順に並んでいきます。
そして便利なのが「キー」をフィルタしますの部分に文字入力すると、そのキーのみの一覧表示ができます。
このログとキーについては、よく考えて設計すると、デバッグにかなり効果を発揮しそうです。

非重大

想定されるExceptionが起こった時、それをそのまま渡して集計ができます。
「クラッシュ」ではなく「非重大」の方にまとまります。

Crashlytics.logException(Exception);
// 自分でExceptionのインスタンスを作って渡す事もできます。
Crashlytics.logException(
  new IllegalArgumentException("IllegalArgumentExceptionのテストです")
);

こっちは、左上の「フィルタ」をクリックして非重大に切り替えて確認しましょう。
こちら上に記載したユーザー情報を入れる、ログやキーを入れる
をしておくと非重大の方の詳細にも同じように反映されます。

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

SwiftUI で星型のシェイプを作ってみる

はじめに

昔の記事で、UIKit で星型の図形を作ってみたことがあったのですが、勉強ついでに SwiftUI でも試してみました。次のようなビューを作成してみます。

正五角形 正六角形
Simulator Screen Shot - iPhone SE (2nd generation) - 2020-07-04 at 21.46.56.png Simulator Screen Shot - iPhone SE (2nd generation) - 2020-07-04 at 21.47.38.png
正七角形 正八角形
Simulator Screen Shot - iPhone SE (2nd generation) - 2020-07-04 at 21.47.52.png Simulator Screen Shot - iPhone SE (2nd generation) - 2020-07-04 at 21.48.10.png

環境

  • Xcode Version 11.5 (11E608c)

実装方針

星型の図形は、次の方法で作図します。

  1. 星型の多角形の外接円と内接円を作る
  2. 外接正多角形の頂点の数 n に対して、円を 2n 等分の扇形に分割する直線を引く
  3. 直線と外接円の交点、内接円の交点を交互に取得する

あとは、取得した頂点を直線で結ぶだけです。正五角形の場合をアニメーションで表すとこんな感じ。
star_outline.gif

基本的なデータ型を実装する

まず、SwiftUI に依存しないデータ構造を実装します。この準備をすることで、座標の位置計算など、SwiftUI フレームワークに依存しない処理を分離することができます。

PolarCoordinate.swift
import 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.swift
import 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 を返す実装を要求します。

SwiftUI
public protocol Shape : Animatable, View {
    func path(in rect: CGRect) -> Path
}

ここで星型の Path を作成することになります。Path は図形の経路を表現した構造体です。

星型のような多角形は、直線で囲まれた閉じた経路として表現されるので、次の手順で作成します。

move(to:)

まず、図形の開始地点の頂点に移動します。

addLine(to:)

次に、次の頂点に向かって、逐次、線を引いていきます。

closeSubpath()

最後に経路を閉じます。

実装例

実際の実装は次の通りです。

StarShape.swift
import 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

プレビューによって、次のように表示されます。
Screen Shot 2020-07-04 at 23.11.55.png
プレビューでは、Attribute Inspector でプロパティを変更できて、変更ごとに図形が更新されるので面白いです。
StarShape.mov.gif

内部の塗りつぶし

StarShape を Shape として実装したことで、利用する側で自由に図形内部を塗りつぶすことができます。
塗りつぶしには、Shapefill を使用します。

StarView.swift
import 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)
    }
}

PreviewProviderGroup を使って、まとめて表示確認ができて便利です。

#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

こんな感じでプレビューされます。
Screen Shot 2020-07-05 at 13.38.34.png

内部と枠線の塗りつぶし

さて、Shape は内部の塗りつぶしだけでなく stroke を使って、枠線を塗りつぶす事もできます。

StarShape(vertex: vertex, smoothness: smoothness)
    .stroke(style, lineWidth: 4)
    .aspectRatio(1.1, contentMode: .fit)

しかし、fillstroke は同時にはできません。fillstrokeShape を返さずに View を返します。ViewShape と異なり、fillstroke メソッドを持っていないため、これが原因でコンパイルエラーになります。

StarShape(vertex: vertex, smoothness: smoothness)
    .fill(style1)
    .stroke(style2, lineWidth: 4) // コンパイルエラー
    .aspectRatio(1.1, contentMode: .fit)

この場合は、fillstroke で別の 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 を適当に設定してプレビューしてみます。
Screen Shot 2020-07-05 at 14.47.58.png
実装はこんな感じです。

#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)
        )
    }
}
#endif
Styles.swift
import 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 で遊んでみて色々と試していきたいところです。

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

[Swift]Gameplaykit Programming Guide超要約

GameplayKit Programming Guide

GameplayKit Programming Guide

GameplayKitとはどんなものか

  • ゲームを作る際のアーキテクチャ 設計・構築
  • ゲームを作る上で必要な複雑なアルゴリズムの構築をサポート
  • 他の上位層のゲーム用ライブラリとは独立しており、ゲームを作る際自由に組み合わせて使うことができる。
    • 例えば、Gamekitと、2Dゲームの場合はSpriteKit, 3Dゲームの場合はSceneKit, サードパーティ製のエンジンを使いたい場合はMetal や OpenGLESを組み合わせて使える。グラフィックがさほど要らないゲームならUIKitと組み合わせてもいい。
  • GameplayKitのカバーする領域
    • ランダム化
      • 乱数を提供する。
    • エンティティ-コンポーネントアーキテクチャ
      • ある動作を定義したコンポーネントをオブジェクトに複数つけることで、ゲーム上に多様なオブジェクトを定義するためのアーキテクチャ 。
    • ステートマシン
      • ゲーム内のオブジェクトがどの状態においてどの動きをするか、またどの状態からどの状態へ移行できるかを簡単に記述するための機能。
    • ミニマックス法
      • オセロ・将棋などの完全情報ゲームにおいて、コンピュータ(人工知能)の動きを実装するために用いる。
    • 経路検索
      • ゲーム盤、フィールド上などでオブジェクトを動かす際の経路の検索に関わるライブラリ。
    • Agent,Goal,Behaviorアーキテクチャ
      • 自律的にゲーム上のオブジェクトを動かすために、オブジェクトに行動の目的(Goal)を設定するためのアーキテクチャ 。
    • ルールシステム
      • ゲーム上に設定するルールを規定する。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[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サブクラスか、SKSceneGLKViewController、その他自分で作ったカスタムクラスなど。
    • そのオブジェクトの中に次のような変数を作る。
    • このような時間経過とは、言い換えればゲーム内で直前のフレームからどのくらい時間が経ったかという事を示す。できれば、1秒間に60フレーム(すなわち、経過時間が0.0166秒 = 16ミリ秒程度)になるのが良いが、処理が重い場合は達成できないこともある。
TimeKeeper.swift
public 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.swift
private 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("アプリが非アクティブになる (ゲームのポーズなどを行う)")
    }
}

なお上に列挙した関数に限っていうと

  1. アプリ起動時にはapplicationDidBecomeActive
  2. ホームボタンなどを押してアプリがバックグラウンドに行った時にはapplicationWillResignActiveapplciationDidEnterBackground
  3. アプリアイコンを押すなどしてアプリが前面に戻った時にはapplicationWillEnterForegroundapplicationDidBecomeActive

が呼ばれる。

このようなアプリのライフサイクルの詳細についてはこちらこちら(iOS13以降)など参照。

  • Timerクラスによるゲームの更新
    • 特定の秒数が経過した後(あるいは、経過する毎)に処理をしたいということがある。その場合、Timerクラスを使うと良い。
GameViewController.swift
    var 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.swift
private 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
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Realm マイグレーションについて

マイグレーションとは

Realmに保存しているモデルに変更があった際には、Migration is required due to the following errorsなどのエラーが発生する。

モデルに変更があると、コード上で変更されたモデルと、すでにディスクにある変更前のモデルで整合性が取れなくなるためである。

下記ページのようにマイグレーションを行うことになる。

Realm doc - Migrations

他方、開発段階ではモデルの変更があるのはよくあることなので、一々マイグレーションを書くのではなく、ディスク上のデータを一回削除してから、コード上の新しいモデルを試せばよいだろう。

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

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などのエラーが発生する。

モデルに変更があると、コード上で変更されたモデルと、すでにディスクにある変更前のモデルで整合性が取れなくなるためである。

下記ページのようにマイグレーションを行うことになる。

Realm doc - Migrations

他方、開発段階ではモデルの変更があるのはよくあることなので、一々マイグレーションを書くのではなく、ディスク上のデータを一回削除してから、コード上の新しいモデルを試せばよいだろう。

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

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 が、上の設定アプリでのオプトアウトと連動しています。

参考:ASIdentifierManager

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の文言は多言語化することができますが、ひと手間かかりますので対応が必要です。

参考:AppTrackingTransparency

AdTest_dialogpng.png

多くのユーザにとっては即座に理解できない許可を求められても許諾するとは思われないので、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.frameworkAppTrackingTransparency.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
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む