しおメモ

雑多な技術系ブログです。ニッチな内容が多いです。

ARCによるオブジェクトの破棄タイミングをずらす

Swiftの場合、特に意識せずとも各オブジェクトに対してARCによるメモリ管理が行われ、コンパイル時にreference countのincrement/decrementの処理(retain/release)が挿入されます。

ほとんどの場合、retain cycleを避けることなどのわずかなことを意識しておけば、メモリ管理はARCに任せておいて問題ないはずです。
逆に、後述のUnmanaged等を利用した、ARCに任せないメモリ管理を行うと、メモリ安全性を損ねることになります。

ただし、ごくまれに、意識外でのオブジェクト解放と実行スレッドの兼ね合いでクラッシュするケースがあり、何度か応急処置した記憶があるので、読み物として書いておきます。

releaseのタイミングと動作スレッドに関連するバグの例

Hogeというクラスがあって、サブスレッドからの操作でreference countが0になるケースを考えます。

final class Hoge {
    // ...

    deinit {
        print("deinit (\(Thread.isMainThread))")
    }
}

var dict: [Int: Hoge] = [1: .init()]
let queue = DispatchQueue(label: "hoge", qos: .background)
let queue2 = DispatchQueue(label: "hoge", qos: .background)
let hoge = Hoge()

queue.async {
    print("Async Start")
    queue2.sync {
        print("Sync Start")
        dict[1] = hoge
        print("Sync End")
    }
    print("Async end")
}

dict[1] = hogeで上書きしたタイミングでreference countが0になるので、Hogedeinitが呼ばれます。

コンパイラによりreleaseが呼ばれるタイミングが決められるのですが、この場合dict[1] = hogeの直後か、ブロックを抜ける直前になります。
したがって、releaseの処理はqueue2で処理されることになります。

Async Start
Sync Start
deinit (false)
Sync End
Async end

一例ですが、HogeUIViewなどだったり、あるいはその中で強参照を持っていると、 UIViewreleaseメインスレッドで動かないことになり、このコードはクラッシュを引き起こします。

このようなケースの実践的な解決策としては、弱参照を活用したり、UIKit関連のクラスは状態管理する部分から分けるなど、分かりづらいタイミングで暗黙的にUIViewを解放するコードを書くのを避けるのが良いです。

ただ、実際にこのようなバグに遭遇したケースもそうなのですが、SDK経由のオブジェクトでそこまで修正が届かないという場合もあるので、他の解決策も考えてみます。

変数に代入してreference countを増やす解決策

syncの意図を維持するため、dictへの操作はatomicのままにしつつ、releaseの処理だけ後回しすることを考えます。
無難かつ愚直に実装するなら、別の変数でreferenceを持ってあげて、main threadで解放してあげるなどでしょうか。

queue.async {
    print("Async Start")
    var tmp: Hoge? = dict[1]
    queue2.sync {
        print("Sync Start")
        dict[1] = hoge
        print("Sync End")
    }
    DispatchQueue.main.async {
        _ = tmp // warning抑制
        tmp = nil
    }
    print("Async end")
}

例えばこのように別の参照を作ってあげれば、reference countが2になるので、dict[1] = hogeでcountが1つ減っても、オブジェクトは破棄されなくなります。

Async Start
Sync Start
Sync End
Async end
deinit (true)

わざわざ参照カウントを増やすためだけに変数宣言と代入をしているため、読みづらさはあるものの、全体としてはARCの管理から外れていないので、比較的安全と言えます。

Unmanagedを使う解決策(非推奨)

ARCの管理に頼らず、手動でreference countを操作するためにUnmanagedというクラスも用意されています。Objective-Cのmanual reference countingとほぼ同じです。

Reference countを増やしておく考え方は上のやり方と同じですが、こちらはARCのようなreference countの整合性は保証されないので、retainreleaseを呼び間違えると別のバグの原因にもなります。

https://developer.apple.com/documentation/swift/unmanaged

わざわざメモリ安全性を壊すようなコードを書く必要もなく、プロダクトでは非推奨ではあるものの、せっかくクラスやドキュメントがあるので、勉強のためにも実装してみます。

var dictUnmanaged: [Int: Unmanaged<Hoge>] = [1: .passRetained(.init())]

queue.async {
    print("Async Start")
    queue2.sync {
        print("Sync Start")
        DispatchQueue.main.async {
            dictUnmanaged[1]?.release()
        }
        dictUnmanaged[1] = .passRetained(hoge)
        print("Sync End")
    }
    print("Async end")
}
Async Start
Sync Start
Sync End
Async end
deinit (true)

Unmanagedでwrapした場合、dictUnmanaged[1] = .passRetained(hoge)のように上書きをしても中のインスタンスのreference countは減りません。

このため、リークや二重解放を引き起こす危険性と引き換えではあるものの、任意のタイミングでインスタンスを解放することができます。

関連

WWDC21でARCについてのセッションがありました。こちらも参考にしてみてください。

https://developer.apple.com/videos/play/wwdc2021/10216/

CADisplayLinkを用いたアニメーション実装

複雑なアニメーションを実装する場合に、画面の更新(垂直同期)に合わせて、アニメーションの更新処理をしたいケースがあります。
単純にTimerなどで実装した場合、アニメーションの処理が呼ばれるタイミングが画面の更新と合わないため、次の垂直同期までに間に合わない可能性があり、 結果としてanimation hitchを生じさせる原因となります。

そのような問題を回避するために、垂直同期の直後のタイミングでの呼び出しが行われるCADisplayLinkを利用することができます。

Timerを利用した場合に困るケース

iOSに限らずですが、大体のモバイルアプリやゲームでは、ダブルバッファリングや、トリプルバッファリングを用いて描画をしています。
ダブルバッファリングでは、表裏の2枚のスクリーンがあり、今見えている画面の裏で次のフレームの準備をしつつ、垂直同期のタイミングに合わせて表裏を入れ替えています。

例えば、60Hzのリフレッシュレートのディスプレイで、最大のパフォーマンスを出す場合、 1フレームあたりおよそ16ms以内に次のフレームの描画の準備を終える必要があります。
仮に処理が重すぎて、次の垂直同期までに間に合わない場合は、以前のフレームがそのまま描画される状態なります。
(iOSの文脈では、animation hitchと呼ばれています)

developer.apple.com

Timerの場合は特に垂直同期のタイミングとは関係なく、startしたタイミングから一定の間隔でcallbackが呼ばれます。 アニメーションの処理の開始が垂直同期のタイミングとずれることで、次の垂直同期までに動ける時間が短くなるため、後述のCADisplayLinkよりも不利になります。
(これについては、以下のWWDCの発表がわかりやすいです)

developer.apple.com

1秒に1回くらいの更新ではTimerでもほとんどのケースで困らないのですが、 高頻度になるほど準備の時間がより短くなるので、60Hz、120Hzのリフレッシュレートに合わせようとした場合は不適切と言えます。

CADisplayLinkの使い方

リフレッシュレートと同調させるような、高頻度なアニメーションの更新をしたい場合には、TimerよりもCADisplayLinkが適しています。 CADisplayLinkRunLoopに紐付けて使用します。

let displayLink = CADisplayLink(target: self, selector: #selector(update(_:)))
displayLink.add(to: .main, forMode: .default)

@objc func update(_ displayLink: CADisplayLink) { ... }

上記の場合は、垂直同期のすぐ後にupdateが呼ばれ、効率よく次のフレームの準備を進めることができます。

現行のiPhoneやiPadのリフレッシュレートは60Hzや120Hzですが、 アプリの処理の重さや端末の状態、設定によって、実際のフレームレートは30fpsであったり、60fpsであったりと異なります。

そのため、Timerで一定間隔に固定にしても、期待通りのフレームレートで動くとは限らず、過剰に呼び出されることもあり、効率の面でもあまり良い書き方とは言えません。
(特にiPhone 13 Proなどの端末では、最大120Hzの可変リフレッシュレートとなっているため、さらに配慮が必要です。)

また、CADisplayLinkの場合はpreferredFramesPerSecondというプロパティで、callbackが呼ばれる頻度を大まかに設定することができます。

displayLink.preferredFramesPerSecond = 60

ただし、iOS15でdeprecatedになっていて、代わりにiOS15+では拡張されたpreferredFrameRateRangeが使えます。

displayLink.preferredFrameRateRange = .init(minimum: 15, maximum: 60, preferred: 60)

例えば、60fpsに設定しておくと、リフレッシュレートが120Hzの端末でも毎秒60回の更新に抑えられますが、 あくまでpreferredなので、元々30fpsしか出ていないようなケースでは、callbackが呼ばれる回数はおおよそ毎秒30回となります。

何も設定しなければ、自動で最大のfpsを取るようになるので、更新の頻度を抑える必要がない場合は設定しないでもOKです。

フレーム毎の時間の計算

先ほども書いたように、端末の種類や、設定、アプリによって実際のフレームレートは異なるため、 アニメーションがどの環境でも同じように見えるようにするためには、フレーム毎の時間を計測するのが適切です。

例えば、1回のcallbackで6度回転するようなアニメーションを実装した場合、 60fpsでは1秒に360度回転しますが、30fpsでは1秒に180度しか回転しません。
このように環境によってアニメーションの速度が変わってしまうので、意図した速さでアニメーションを動かすには、 実際に1フレームあたりどれくらいの時間がかかっているかを知ることが必要になります。

CADisplayLinkにはtimestamptargetTimestampの2種類のプロパティがありますが、timestampは今準備しているフレームの開始時刻で、targetTimestampは今準備しているフレームが表示される予定時刻なので、アニメーションに時刻を利用する場合はtargetTimestampが適切になります。

Always use targetTimestamp to drive any animation, physics, or other time-related content provided in your CADisplayLink callback.

実際には、高負荷などで処理が間に合わない際に、callbackがスキップされるケースがあるので、 今のフレームのtargetTimestampが次のフレームのtimestampに一致するとは限りません。
したがって、厳密にアニメーションの計算のために時間の差分を取るには、targetTimestamp - timestampよりも、前回のtargetTimestampと比較をするのがより正確な手段になります。

var previousTimeStamp: CFTimeInterval = .zero

func start() {
    let displayLink = CADisplayLink(target: self, selector: #selector(update(_:)))
    displayLink.add(to: .main, forMode: .default)
    previousTimeStamp = CACurrentMediaTime()
}

@objc func update(_ displayLink: CADisplayLink) {
    let delta = displayLink.targetTimestamp - previousTimeStamp
    previousTimeStamp = displayLink.targetTimestamp

    // ...
}

先ほどの例のように、1秒に360度回転させるようなアニメーションをしたい場合には、以下のようになります。

@objc func update(_ displayLink: CADisplayLink) {
    let delta = displayLink.targetTimestamp - previousTimeStamp
    previousTimeStamp = displayLink.targetTimestamp

    angle += CGFloat.pi * 2 * delta
}

CADisplayLinkTimerとは異なり、呼び出しの間隔が一定でない場合がありますが、 経過時間を考慮することで、環境によらず意図したスピードでアニメーションを動かすことができます。

リンク

書いている途中で試聴したのですが、以下のWWDC21の発表の後半でわかりやすくまとまっているので、そちらが参考になります。
iPhone13 Proなどの可変リフレッシュレートのディスプレイ対応についてもAppleのドキュメントがあります。

Cellに直接addSubviewするコードに警告を出す

ちょっと前のアップデートで、正しくcontentViewに追加していない場合に、セルの挙動がおかしくなるケースが多発したと思います。
この前Xcode13に上げたタイミングでも食らってしまい、 cellに直接addSubviewしたいケースも多分ないと思ったので、作ってみました。

lintとかでやると大掛かりになるので、雑にdeprecatedにしてしまえという発想です。

#if DEBUG

import UIKit

extension UITableViewCell {
    @available(*, deprecated, message: "Subviews should be added to the cell's contentView")
    override open func addSubview(_ view: UIView) {
        super.addSubview(view)
    }
}

extension UICollectionViewCell {
    @available(*, deprecated, message: "Subviews should be added to the cell's contentView")
    override open func addSubview(_ view: UIView) {
        super.addSubview(view)
    }
}

#endif

警告だらけで埋もれてしまう場合、deprecatedunavailableにすればビルドが通らなくなります。