一、为啥要关心这个性能问题

咱们做 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 / endUpdatesinsertRows / 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 开销。
  • 提升代码的可维护性和可测试性(通过分层解耦)。

技术缺点:

  • 实现成本高,需要额外编写缓存、分页、预加载等逻辑。
  • 容易出错,比如缓存更新不及时、分页重复请求等。
  • 对开发者要求较高,需要理解多线程和内存管理。

六、注意事项

  1. 内存警告处理:一定在收到 didReceiveMemoryWarning 时清理图片缓存、高度缓存等大内存对象,否则 App 容易被系统杀死。
  2. 网络状态变化:如果网络断开,应该暂停网络请求,等恢复后再继续。可以用 NWPathMonitor 监听。
  3. 图片缓存大小:磁盘缓存不要无限制增长,设定一个上限(比如50MB),超出后清理最久未使用的图片。
  4. 避免过度优化:比如数据量很小的列表,没必要做复杂的增量更新,直接 reloadData 即可。
  5. 测试不同网络速度:用 Xcode 的 Network Link Conditioner 模拟弱网,检查分页和加载策略是否正常。
  6. 线程安全:NSCache 本身就是线程安全的,但自定义的字典缓存需要加锁。

七、总结

UIKit 框架下的网络请求与数据展示优化,本质上是把“慢”的操作移到后台,把“快”的操作留给主线程。从请求合并、缓存策略,到数据解析、图片解压,再到 cell 复用和增量更新,每个环节都藏着提升性能的机会。作为开发者,我们不需要在每个项目里都用上所有技巧,但至少要理解它们背后的原理,在遇到性能瓶颈时知道从哪里下手。记住:用户要的从来不是多么炫酷的动画,而是“滑一下,内容就出来”的爽快感。