一、为啥要关心这个性能问题
咱们做 iOS 开发,天天跟网络请求和列表展示打交道。你是不是也遇到过这样的场景:用户刷一下列表,页面卡得跟幻灯片一样;或者图片半天不出来,用户等得不耐烦直接删了 App。其实这些问题的根源往往就是网络请求和数据展示的配合没做好。比如数据一次性全拉下来,几十个 cell 同时渲染,主线程忙不过来;又比如图片下载完后没有做子线程解压,直接在 cell 上卡了一下。今天咱们就用最接地气的方式,聊聊怎么用 UIKit 把这些事情安排得明明白白。
二、网络请求层面的优化
网络请求是数据的源头,源头慢了或者堵了,后面再怎么优化展示都没用。
2.1 利用 URLSession 的缓存策略
苹果自带的 NSURLCache 特别好用,它能帮我们把请求结果缓存到内存和磁盘里。下次同样的请求直接读缓存,连网络都不走。咱们可以这样配置:
// 技术栈:Swift + URLSession
import Foundation
// 1. 创建缓存对象:内存10MB,磁盘20MB
let cache = URLCache(memoryCapacity: 10 * 1024 * 1024,
diskCapacity: 20 * 1024 * 1024,
diskPath: "myCache")
// 2. 配置 Session
let config = URLSessionConfiguration.default
config.urlCache = cache
// 设置缓存策略:先读缓存,如果过期再请求网络
config.requestCachePolicy = .returnCacheDataElseLoad
// 3. 创建 Session 并用它发起请求
let session = URLSession(configuration: config)
let url = URL(string: "https://api.example.com/users")!
let task = session.dataTask(with: url) { data, response, error in
// 处理数据...
if let data = data {
print("拿到数据了,可能来自缓存也可能来自网络")
}
}
task.resume()
注意:缓存策略要根据业务来定。如果数据经常变,用 .reloadRevalidatingCacheData 更合适。另外别忘了在内存警告时清除缓存:
// 收到内存警告时清理缓存
NotificationCenter.default.addObserver(forName: UIApplication.didReceiveMemoryWarningNotification,
object: nil, queue: .main) { _ in
URLCache.shared.removeAllCachedResponses()
}
2.2 请求合并与去重
比如一个界面里多个 cell 都要用同一个用户头像,如果每个 cell 都发一次网络请求,那简直是浪费流量和电量。我们可以用一个工具类来管理请求,相同URL的请求只发起一次,然后所有等待者共用同一个回调。
// 技术栈:Swift + URLSession + NSCache
import Foundation
class ImageLoader {
static let shared = ImageLoader()
private let session = URLSession(configuration: .default)
// 用 NSCache 缓存已下载的图片 Data(避免重复下载)
private var cache = NSCache<NSURL, NSData>()
// 管理正在进行的请求任务,key 是 URL 字符串,value 是回调数组
private var pendingTasks = [String: [(Data?) -> Void]]()
func loadImage(url: URL, completion: @escaping (Data?) -> Void) {
// 1. 检查内存缓存
if let cachedData = cache.object(forKey: url as NSURL) {
completion(cachedData as Data)
return
}
let urlString = url.absoluteString
// 2. 如果已经有相同URL的任务在进行,把回调加到队列中(请求合并)
if var handlers = pendingTasks[urlString] {
handlers.append(completion)
pendingTasks[urlString] = handlers
return
}
// 3. 新建任务
pendingTasks[urlString] = [completion]
let task = session.dataTask(with: url) { [weak self] data, _, error in
guard let self = self else { return }
// 4. 取回所有等待该URL的回调
let handlers = self.pendingTasks.removeValue(forKey: urlString) ?? []
guard let data = data, error == nil else {
// 所有回调都传递 nil
DispatchQueue.main.async {
handlers.forEach { $0(nil) }
}
return
}
// 5. 缓存数据
self.cache.setObject(data as NSData, forKey: url as NSURL)
// 6. 回到主线程,调用所有回调
DispatchQueue.main.async {
handlers.forEach { $0(data) }
}
}
task.resume()
}
}
使用的时候,每个 cell 只需要调用 ImageLoader.shared.loadImage(url: avatarUrl) 就行,多个 cell 使用同一个 URL 时,只会发起一次网络请求。
2.3 分页加载与预加载
一次性拉几百条数据,会让用户等很久,而且内存暴涨。更好的做法是一次只加载一页(比如20条),然后通过滚动监听提前加载下一页。
// 技术栈:Swift + UITableView
import UIKit
class UserListViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
var users = [User]()
var currentPage = 0
let pageSize = 20
var isLoadingMore = false
override func viewDidLoad() {
super.viewDidLoad()
loadPage(page: 0) // 加载第一页
}
// 分页加载数据
func loadPage(page: Int) {
guard !isLoadingMore else { return }
isLoadingMore = true
let url = URL(string: "https://api.example.com/users?page=\(page)&size=\(pageSize)")!
URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
guard let self = self else { return }
defer { self.isLoadingMore = false }
guard let data = data, let newUsers = try? JSONDecoder().decode([User].self, from: data) else {
return
}
DispatchQueue.main.async {
self.users.append(contentsOf: newUsers)
self.tableView.reloadData()
}
}.resume()
}
}
// 在 UIScrollView 代理中检测滚动到接近底部时预加载下一页
extension UserListViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
let visibleHeight = scrollView.frame.height
// 当滚动到距离底部还有200点时,触发加载更多
if offsetY + visibleHeight > contentHeight - 200 {
loadPage(page: currentPage + 1)
}
}
}
这样做的好处是:用户只看到当前屏幕的数据,加载速度快,滑动流畅。
三、数据解析与模型转换的优化
拿到网络返回的 JSON 后,我们需要转成模型。这个过程如果放在主线程,会阻塞 UI。
3.1 选择合适的解析方式
JSONSerialization 比 Codable 快,但写起来麻烦。对于性能要求高的场景(比如解析几百条数据),建议用 JSONSerialization 然后在子线程转模型。
// 技术栈:Swift + JSONSerialization
import Foundation
// 使用串行队列进行解析,不阻塞主线程
let parseQueue = DispatchQueue(label: "com.example.parse")
func parseUsers(from data: Data, completion: @escaping ([User]) -> Void) {
parseQueue.async {
guard let jsonArray = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
DispatchQueue.main.async { completion([]) }
return
}
var users = [User]()
for json in jsonArray {
guard let id = json["id"] as? Int,
let name = json["name"] as? String else { continue }
let user = User(id: id, name: name)
users.append(user)
}
// 回到主线程回调
DispatchQueue.main.async {
completion(users)
}
}
}
如果数据量不大,用 Codable 更省事,它内部也会自动在后台解析(在 dataTask 的闭包里本身就在后台线程)。所以一般直接用 Codable 就够了。
3.2 延迟加载与懒加载
不要在 cell 的 cellForRowAt 里去做复杂的模型转换或计算,应该提前在数据源中准备好展示用的数据。比如预先算出 cell 的高度,或者把日期格式提前转成字符串。
// 技术栈:Swift + Model
struct UserDisplayItem {
let id: Int
let displayName: String
let avatarURL: URL?
let ageText: String // 已经格式化好的年龄文本
init(from user: User) {
id = user.id
displayName = user.name.isEmpty ? "匿名" : user.name
avatarURL = URL(string: user.avatar)
ageText = "\(user.age)岁"
}
}
在获取到原始 User 数组后,立刻转成 UserDisplayItem 数组,这样在 UI 展示时就不需要再做任何计算。
四、UI展示层面的优化
这一步是用户直接感受的,卡不卡就看这里。
4.1 异步加载图片与解压缩
很多开发者直接在设置图片的时候用 UIImage(data: data),但这样会导致图片在主线程解压,非常消耗 CPU。正确做法是在子线程解压,然后把解压后的 UIImage 传给主线程设置。
// 技术栈:Swift + UIKit + CGContext
import UIKit
// 在子线程解压图片
func decompressImage(from data: Data) -> UIImage? {
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
return nil
}
// 用位图上下文重新绘制,触发解压
let width = cgImage.width
let height = cgImage.height
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGImageAlphaInfo.premultipliedFirst.rawValue
guard let context = CGContext(data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: bitmapInfo) else {
return UIImage(cgImage: cgImage)
}
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
guard let decompressedCGImage = context.makeImage() else {
return UIImage(cgImage: cgImage)
}
return UIImage(cgImage: decompressedCGImage)
}
// 在网络请求完成的后台队列中调用
DispatchQueue.global().async {
guard let data = try? Data(contentsOf: imageURL) else { return }
let image = decompressImage(from: data) // 解压在子线程
DispatchQueue.main.async {
cell.imageView.image = image
}
}
如果你用了 SDWebImage 或 Kingfisher,它们内部已经做好了子线程解压,直接用即可,但了解原理还是很有必要的。
4.2 复用机制与Cell高度缓存
UITableView 的复用是 UIKit 的黄金法则,但要注意不要在复用后的 cell 里残留旧数据。同时,如果 cell 高度是动态的,每次计算高度都很费时,建议把高度存起来。
// 技术栈:Swift + UITableView
class UserListViewController: UIViewController {
// 用字典缓存高度,key 可以是 indexPath 或模型 ID
var heightCache = [IndexPath: CGFloat]()
override func viewDidLoad() {
super.viewDidLoad()
tableView.estimatedRowHeight = 80 // 给个估计值,让table更快计算contentSize
}
}
// 实现 UITableViewDelegate 的高度方法
extension UserListViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
// 1. 先从缓存取
if let cachedHeight = heightCache[indexPath] {
return cachedHeight
}
// 2. 计算真实高度(比如用 auto layout 或手动计算)
let user = users[indexPath.row]
let height = calculateHeight(for: user)
// 3. 存入缓存
heightCache[indexPath] = height
return height
}
// 当数据源变动时,记得清除相关缓存
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// 可选:在cell不再显示时清除该indexPath的高度缓存,避免占用过多内存
// 但通常我们保留缓存以提升快速滚动体验
}
}
4.3 数据源的增量更新
如果列表数据新增或删除了几条,千万不要直接 reloadData,那会让所有 cell 重新渲染,非常浪费。应该用 beginUpdates / endUpdates 和 insertRows / deleteRows 等方法。
// 技术栈:Swift + UITableView
extension UserListViewController {
func insertUsers(_ newUsers: [User], at index: Int) {
// 假设先更新数据源
users.insert(contentsOf: newUsers, at: index)
// 计算需要插入的 indexPaths
let indexPaths = (index..<index + newUsers.count).map { IndexPath(row: $0, section: 0) }
tableView.beginUpdates()
tableView.insertRows(at: indexPaths, with: .automatic)
tableView.endUpdates()
}
}
如果更新比较复杂(比如排序、替换),可以考虑使用 IGListKit 或者苹果自家的 NSDiffableDataSource(iOS 13+),它会自动计算差异并执行最小更新。
// 技术栈:Swift + UITableViewDiffableDataSource (iOS 13+)
class UserListViewController: UIViewController {
var dataSource: UITableViewDiffableDataSource<Section, User>!
override func viewDidLoad() {
super.viewDidLoad()
dataSource = UITableViewDiffableDataSource<Section, User>(tableView: tableView) { tableView, indexPath, user in
let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell", for: indexPath)
// 配置cell...
return cell
}
// 初始加载
var snapshot = NSDiffableDataSourceSnapshot<Section, User>()
snapshot.appendSections([.main])
snapshot.appendItems(initialUsers)
dataSource.apply(snapshot, animatingDifferences: true)
}
func updateUsers(_ newUsers: [User]) {
var snapshot = dataSource.snapshot()
snapshot.deleteAllItems()
snapshot.appendSections([.main])
snapshot.appendItems(newUsers)
dataSource.apply(snapshot, animatingDifferences: true) // 自动动画
}
}
enum Section {
case main
}
五、应用场景与技术优缺点
应用场景:
- 社交 App 的 feed 流
- 电商商品列表
- 新闻资讯列表
- 图片瀑布流(像小红书)
- 即时通讯中的聊天记录
这些场景的共同点是:网络请求频繁、数据量大、图片多、用户滚动速度快。如果不做优化,轻则掉帧,重则崩溃。
技术优点:
- 用户体验丝滑,加载快速,减少等待感。
- 节省流量和电量,降低 App 开销。
- 提升代码的可维护性和可测试性(通过分层解耦)。
技术缺点:
- 实现成本高,需要额外编写缓存、分页、预加载等逻辑。
- 容易出错,比如缓存更新不及时、分页重复请求等。
- 对开发者要求较高,需要理解多线程和内存管理。
六、注意事项
- 内存警告处理:一定在收到
didReceiveMemoryWarning时清理图片缓存、高度缓存等大内存对象,否则 App 容易被系统杀死。 - 网络状态变化:如果网络断开,应该暂停网络请求,等恢复后再继续。可以用
NWPathMonitor监听。 - 图片缓存大小:磁盘缓存不要无限制增长,设定一个上限(比如50MB),超出后清理最久未使用的图片。
- 避免过度优化:比如数据量很小的列表,没必要做复杂的增量更新,直接 reloadData 即可。
- 测试不同网络速度:用 Xcode 的 Network Link Conditioner 模拟弱网,检查分页和加载策略是否正常。
- 线程安全:NSCache 本身就是线程安全的,但自定义的字典缓存需要加锁。
七、总结
UIKit 框架下的网络请求与数据展示优化,本质上是把“慢”的操作移到后台,把“快”的操作留给主线程。从请求合并、缓存策略,到数据解析、图片解压,再到 cell 复用和增量更新,每个环节都藏着提升性能的机会。作为开发者,我们不需要在每个项目里都用上所有技巧,但至少要理解它们背后的原理,在遇到性能瓶颈时知道从哪里下手。记住:用户要的从来不是多么炫酷的动画,而是“滑一下,内容就出来”的爽快感。
Comments