performance-optimization について
performance-optimizationは、SwiftおよびSwiftUIアプリの開発者向けの性能判断ガイドです。変更前のプロファイリングを重視し、画面更新、メモリ管理、画像読み込み、バックグラウンド処理のパターンを紹介します。
主な機能
- 01プロファイリングツール選択の指針
- 02SwiftUIビュー更新の戦略
- 03メモリリークとキャッシュの対策
- 04画像ダウンサンプリングのパターン
- 05バックグラウンド処理の推奨事項
活用シーン
- 01画面のカクつきやフレーム落ちの調査
- 02メモリ増加や循環参照の追跡
- 03SwiftUIビューの反復計算の削減
- 04メモリ制約下でのサムネイル読み込み
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ kaakati/ rails-enterprise-dev --skill performance-optimizationインストール後、Claude Code で/performance-optimizationを入力して読み込まれたか確認します。
同梱ファイルreferences/complete-performance-toolkit.md
詳細
パフォーマンス最適化 — エキスパートの判断
パフォーマンスに関する選択のための、エキスパート向け判断フレームワークです。Claude は遅延読み込みや非同期処理の基本を把握しています。このスキルでは、最適化すべきタイミングと使用するツールを判断します。
判断ツリー
最適化すべきですか?
When should you invest in optimization?
├─ User-facing latency issue (visible stutter/delay)
│ └─ YES — Profile and fix
│ Measure first, optimize second
│
├─ Premature concern ("this might be slow")
│ └─ NO — Wait for evidence
│ Write clean code, profile later
│
├─ Battery drain complaints
│ └─ YES — Use Energy Diagnostics
│ Focus on background work, location, network
│
├─ Memory warnings / crashes
│ └─ YES — Use Allocations + Leaks
│ Find retain cycles, unbounded caches
│
└─ App store reviews mention slowness
└─ YES — Profile real scenarios
User perception matters
落とし穴:思い込みに基づいて最適化することです。まず必ずプロファイリングしてください。ボトルネックは、考えている場所とは異なることがほとんどです。
プロファイリングツールの選択
What are you measuring?
├─ Slow UI / frame drops
│ └─ Time Profiler + View Debugger
│ Find expensive work on main thread
│
├─ Memory growth / leaks
│ └─ Allocations + Leaks instruments
│ Track object lifetimes, find cycles
│
├─ Network performance
│ └─ Network instrument + Charles/Proxyman
│ Latency, payload size, request count
│
├─ Disk I/O issues
│ └─ File Activity instrument
│ Excessive reads/writes
│
├─ Battery drain
│ └─ Energy Log instrument
│ CPU wake, location, networking
│
└─ GPU / rendering
└─ Core Animation instrument
Offscreen rendering, overdraw
SwiftUI のビュー更新戦略
View is re-rendering too often?
├─ Caused by parent state changes
│ └─ Extract to separate view
│ Child doesn't depend on changing state
│
├─ Complex computed body
│ └─ Cache expensive computations
│ Use ViewModel or memoization
│
├─ List items all updating
│ └─ Check view identity
│ Use stable IDs, not indices
│
├─ Observable causing cascading updates
│ └─ Split into multiple @Published
│ Or use computed properties
│
└─ Animation causing constant redraws
└─ Use drawingGroup() or limit scope
Rasterize stable content
メモリ管理の判断
How to fix memory issues?
├─ Steady growth during use
│ └─ Check caches and collections
│ Add eviction, use NSCache
│
├─ Growth tied to navigation
│ └─ Check retain cycles
│ weak self in closures, delegates
│
├─ Large spikes on specific screens
│ └─ Downsample images
│ Load at display size, not full resolution
│
├─ Memory not released after screen dismissal
│ └─ Debug object lifecycle
│ deinit not called = retain cycle
│
└─ Background memory pressure
└─ Respond to didReceiveMemoryWarning
Clear caches, release non-essential data
絶対にしないこと
ビューの識別性
識別子としてインデックスを絶対に使用しないでください:
// ❌ Identity changes when array mutates
List(items.indices, id: \.self) { index in
ItemRow(item: items[index])
}
// Insert at index 0 → all views recreated!
// ✅ Use stable identifiers
List(items) { item in
ItemRow(item: item)
.id(item.id) // Stable across mutations
}
body 内で負荷の高い値を絶対に計算しないでください:
// ❌ Called on every render
var body: some View {
let sortedItems = items.sorted { $0.date > $1.date } // O(n log n) per render!
let filtered = sortedItems.filter { $0.isActive }
List(filtered) { item in
ItemRow(item: item)
}
}
// ✅ Compute in ViewModel or use computed property
@MainActor
class ViewModel: ObservableObject {
@Published var items: [Item] = []
var displayItems: [Item] {
items.filter(\.isActive).sorted { $0.date > $1.date }
}
}
状態管理
渡されたオブジェクトに @StateObject を絶対に使用しないでください:
// ❌ Creates new instance on every parent update
struct ChildView: View {
@StateObject var viewModel: ChildViewModel // Wrong!
var body: some View { ... }
}
// ✅ Use @ObservedObject for passed objects
struct ChildView: View {
@ObservedObject var viewModel: ChildViewModel // Parent owns it
var body: some View { ... }
}
すべてを @Published に絶対にしないでください:
// ❌ Every property change triggers view updates
class ViewModel: ObservableObject {
@Published var items: [Item] = []
@Published var internalCache: [String: Data] = [:] // UI doesn't need this!
@Published var isProcessing = false // Maybe internal only
}
// ✅ Only publish what UI observes
class ViewModel: ObservableObject {
@Published var items: [Item] = []
@Published var isLoading = false
private var internalCache: [String: Data] = [:] // Not @Published
private var isProcessing = false // Private state
}
メモリリーク
エスケープクロージャで self を強参照絶対にしないでください:
// ❌ Retain cycle — never deallocates
class ViewModel {
var timer: Timer?
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
self.tick() // Strong capture!
}
}
}
// ✅ Weak capture + invalidation
class ViewModel {
var timer: Timer?
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.tick()
}
}
deinit {
timer?.invalidate()
}
}
オブザーバーの削除を絶対に忘れないでください:
// ❌ Leaks observer and potentially self
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(
self,
selector: #selector(handleNotification),
name: .userLoggedIn,
object: nil
)
// Never removed!
}
}
// ✅ Remove in deinit or use modern API
class ViewController: UIViewController {
private var observer: NSObjectProtocol?
override func viewDidLoad() {
super.viewDidLoad()
observer = NotificationCenter.default.addObserver(
forName: .userLoggedIn,
object: nil,
queue: .main
) { [weak self] _ in
self?.handleNotification()
}
}
deinit {
if let observer { NotificationCenter.default.removeObserver(observer) }
}
}
画像の読み込み
サムネイルにフル解像度の画像を絶対に読み込まないでください:
// ❌ 4000×3000 image for 80×80 thumbnail
let image = UIImage(contentsOfFile: path) // Full resolution in memory!
imageView.image = image
// ✅ Downsample to display size
func downsampledImage(at url: URL, to size: CGSize) -> UIImage? {
let options: [CFString: Any] = [
kCGImageSourceShouldCache: false,
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: max(size.width, size.height) * UIScreen.main.scale
]
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
return nil
}
return UIImage(cgImage: cgImage)
}
上限を設けずに画像を絶対にキャッシュしないでください:
// ❌ Unbounded memory growth
class ImageLoader {
private var cache: [URL: UIImage] = [:] // Grows forever!
func image(for url: URL) -> UIImage? {
if let cached = cache[url] { return cached }
let image = loadImage(url)
cache[url] = image // Never evicted
return image
}
}
// ✅ Use NSCache with limits
class ImageLoader {
private let cache = NSCache<NSURL, UIImage>()
init() {
cache.countLimit = 100
cache.totalCostLimit = 50 * 1024 * 1024 // 50 MB
}
func image(for url: URL) -> UIImage? {
if let cached = cache.object(forKey: url as NSURL) { return cached }
guard let image = loadImage(url) else { return nil }
cache.setObject(image, forKey: url as NSURL, cost: image.jpegData(compressionQuality: 1)?.count ?? 0)
return image
}
}
負荷の高い処理
メインスレッドで負荷の高い処理を絶対に実行しないでください:
// ❌ UI frozen during processing
func loadData() {
let data = try! Data(contentsOf: largeFileURL) // Blocks main thread!
let parsed = parseData(data) // Still blocking!
self.items = parsed
}
// ✅ Use background thread, update on main
func loadData() async {
let items = await Task.detached(priority: .userInitiated) {
let data = try! Data(contentsOf: largeFileURL)
return parseData(data)
}.value
await MainActor.run {
self.items = items
}
}
必須パターン
効率的なリストビュー
struct EfficientListView: View {
let items: [Item]
var body: some View {
ScrollView {
LazyVStack(spacing: 12) { // Lazy = on-demand creation
ForEach(items) { item in
ItemRow(item: item)
.id(item.id) // Stable identity
}
}
}
}
}
// Equatable row prevents unnecessary updates
struct ItemRow: View, Equatable {
let item: Item
var body: some View {
HStack {
AsyncImage(url: item.imageURL) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Color.gray.opacity(0.3)
}
.frame(width: 60, height: 60)
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading) {
Text(item.title).font(.headline)
Text(item.subtitle).font(.caption).foregroundColor(.secondary)
}
}
}
static func == (lhs: ItemRow, rhs: ItemRow) -> Bool {
lhs.item.id == rhs.item.id &&
lhs.item.title == rhs.item.title &&
lhs.item.subtitle == rhs.item.subtitle
}
}
メモリ安全な ViewModel
@MainActor
final class ViewModel: ObservableObject {
@Published private(set) var items: [Item] = []
@Published private(set) var isLoading = false
private var cancellables = Set<AnyCancellable>()
private var loadTask: Task<Void, Never>?
func load() {
loadTask?.cancel() // Cancel previous
loadTask = Task {
guard !Task.isCancelled else { return }
isLoading = true
defer { isLoading = false }
do {
let items = try await API.fetchItems()
guard !Task.isCancelled else { return }
self.items = items
} catch {
// Handle error
}
}
}
deinit {
loadTask?.cancel()
cancellables.removeAll()
}
}
デバウンス検索
@MainActor
final class SearchViewModel: ObservableObject {
@Published var searchText = ""
@Published private(set) var results: [Item] = []
private var searchTask: Task<Void, Never>?
init() {
// Debounce search
$searchText
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.removeDuplicates()
.sink { [weak self] text in
self?.performSearch(text)
}
.store(in: &cancellables)
}
private func performSearch(_ query: String) {
searchTask?.cancel()
guard !query.isEmpty else {
results = []
return
}
searchTask = Task {
do {
let results = try await API.search(query: query)
guard !Task.isCancelled else { return }
self.results = results
} catch {
// Handle error
}
}
}
}
クイックリファレンス
Instruments の選択
| 問題 | インストゥルメント | 確認する内容 |
|---|---|---|
| UI が遅い | Time Profiler | メインスレッドでの負荷の高い処理 |
| メモリリーク | Leaks | リークしたオブジェクト |
| メモリの増加 | Allocations | 増加しているカテゴリ |
| バッテリー | Energy Log | ウェイクの頻度 |
| ネットワーク | Network | リクエスト数、サイズ |
| ディスク | File Activity | 過剰な I/O |
| GPU | Core Animation | オフスクリーンレンダリング |
SwiftUI パフォーマンスチェックリスト
| 問題 | 解決策 |
|---|---|
| リストのスクロールが遅い | LazyVStack/LazyVGrid を使用する |
| すべての項目が再レンダリングされる | 安定した ID、Equatable の行を使用する |
| body の計算負荷が高い | ViewModel に移動する |
| @Published の更新が連鎖する | 分割するか、computed を使用する |
| アニメーションがカクつく | drawingGroup() を使用する |
メモリ管理
| パターン | 防止できる問題 |
|---|---|
| クロージャ内の [weak self] | 参照サイクル |
| deinit での Timer.invalidate() | タイマーリーク |
| deinit でオブザーバーを削除する | オブザーバーリーク |
| 上限付きの NSCache | キャッシュの無制限な増加 |
| 画像のダウンサンプリング | メモリ使用量の急増 |
カスタムプロファイリングでの os_signpost
import os.signpost
let log = OSLog(subsystem: "com.app", category: .pointsOfInterest)
os_signpost(.begin, log: log, name: "DataProcessing")
// Expensive work
os_signpost(.end, log: log, name: "DataProcessing")
要注意の兆候
| 問題の兆候 | 問題 | 修正方法 |
|---|---|---|
| List の ID にインデックスを使用 | 変更時にビューが再作成される | 安定した識別子を使用する |
| body の計算負荷が高い | レンダリングのたびに実行される | ViewModel に移動する |
| 渡されたオブジェクトに @StateObject を使用 | 新しいインスタンスが作成される | @ObservedObject を使用する |
| Timer/クロージャ内の self が強参照 | 参照サイクル | [weak self] を使用する |
| サムネイルにフル解像度の画像を使用 | メモリが急増する | 表示サイズにダウンサンプリングする |
| 上限のない辞書キャッシュ | メモリが増加する | 上限付きの NSCache を使用する |
| Task.detached を使わずに負荷の高い処理を実行 | メインスレッドをブロックする | バックグラウンド優先度を使用する |
コメント
Markdown 対応