Swift AppKit渲染内存过度消耗问题排查
核心原因分析
1. AppKit视图的惰性Backing Store分配
AppKit的NSView默认采用惰性方式创建图形缓存(backing store),仅在视图需要首次绘制时才会分配内存。你创建的36个SquaresNSView在应用启动时仅完成初始化,并未分配缓存;当首次悬停某个网格的单元格时,触发局部绘制,系统会为该视图创建完整的backing store。
以你的网格尺寸(478x82逻辑点)为例,Retina屏幕下物理像素为956x164,每个像素占4字节(RGBA),单个backing store内存约612KB,与你观察到的"首次染红新网格涨0.5MiB"完全吻合。
2. IndexPath作为字典键的额外开销
cellColors使用IndexPath(引用类型)作为键,每次新增条目都需要创建新的IndexPath实例,首次操作新网格时会产生一批这类对象的内存分配;后续同一网格的操作只是新增更多IndexPath,但单实例内存较小,因此增长幅度降低。
3. 不必要的全局重绘触发
CoreGraphicsSquaresView的updateNSView方法中,每次都调用nsView.setNeedsDisplay(nsView.bounds),这会触发整个视图的重绘,可能导致某些视图提前创建backing store,增加启动后的内存占用。
优化方案
1. 移除不必要的全局重绘
删除updateNSView中的全局重绘调用,因为视图仅在单元格颜色变化时需要局部重绘:
func updateNSView(_ nsView: NSView, context: Context) { // 移除全局重绘调用 }
2. 替换IndexPath为更高效的存储方式
方案A:使用二维数组存储颜色
将字典改为二维数组,避免哈希表的额外开销,同时无需创建IndexPath:
// 在SquaresNSView中替换cellColors定义 var cellColors: [[NSColor?]] // 初始化时创建数组 init(rows: Int, columns: Int, squareSize: CGFloat, spacing: CGFloat) { self.rows = rows self.columns = columns self.squareSize = squareSize self.spacing = spacing // 初始化二维数组,nil表示默认蓝色 self.cellColors = Array(repeating: Array(repeating: nil, count: columns), count: rows) super.init(frame: .zero) // ... 原有trackingArea代码 } // 修改updateSquare方法 func updateSquare(atRow row: Int, column: Int, to color: NSColor) { guard row >= 0, row < rows, column >= 0, column < columns else { return } if cellColors[row][column] == color { return } cellColors[row][column] = color let x = CGFloat(column) * (squareSize + spacing) let y = CGFloat(row) * (squareSize + spacing) let squareRect = CGRect(x: x, y: y, width: squareSize, height: squareSize) self.setNeedsDisplay(squareRect) } // 修改draw方法中的颜色获取 let color = cellColors[row][col] ?? NSColor.blue
方案B:使用元组作为字典键
如果坚持用字典,将键改为(row: Int, col: Int)元组(值类型),减少内存开销:
var cellColors: [(row: Int, col: Int): NSColor] = [:] // 修改updateSquare中的键创建 let key = (row: row, col: column) if cellColors[key] == color { return } cellColors[key] = color
3. 修复鼠标坐标计算逻辑
移除硬编码的12,改用动态计算判断鼠标是否在方块内,避免逻辑错误和不必要的updateSquare调用:
override func mouseMoved(with event: NSEvent) { let locationInView = convert(event.locationInWindow, from: nil) let cellDimension = squareSize + spacing // 计算所在行列 let col = Int(locationInView.x / cellDimension) let row = Int(locationInView.y / cellDimension) // 检查行列是否在有效范围内 guard col >= 0, col < columns, row >= 0, row < rows else { lastHovered = nil return } // 检查鼠标是否在方块内部(而非间距区域) let xOffset = locationInView.x.truncatingRemainder(dividingBy: cellDimension) let yOffset = locationInView.y.truncatingRemainder(dividingBy: cellDimension) guard xOffset <= squareSize, yOffset <= squareSize else { lastHovered = nil return } let currentKey = (row: row, col: col) if lastHovered == currentKey { return } lastHovered = currentKey updateSquare(atRow: row, column: col, to: .red) }
4. 优化TrackingArea的创建
初始化时bounds为.zero,后续视图frame变化时trackingArea范围不会自动更新,导致鼠标追踪可能失效。可以在viewDidChangeFrame中更新trackingArea:
override func viewDidChangeFrame() { super.viewDidChangeFrame() // 移除旧的trackingArea trackingAreas.forEach { removeTrackingArea($0) } // 创建新的trackingArea let trackingArea = NSTrackingArea( rect: bounds, options: [.mouseMoved, .activeAlways, .inVisibleRect], owner: self, userInfo: nil ) addTrackingArea(trackingArea) }
内容的提问来源于stack exchange,提问作者Riccardo Perego

