从踩坑到优雅实践:我在 iOS 工程中的技术探索与落地经验

编译器不爱我
2025-06-24 01:14
阅读 464

起因:一个“简单”功能引发的技术选型思考

起因:一个“简单”功能引发的技术选型思考

记得去年上半年,我们公司要做一个新功能:在用户查看商品详情页时,能够横向滑动浏览相似推荐商品。听起来是不是很简单?UI 是 UICollectionView 的 horizontal layout 就行了嘛。但当你把它放在真实的业务场景中,事情就没那么单纯了。

我们这个 App 上线已经有三年多,代码库相当庞大,而且有多个业务线并行开发,很多底层逻辑都盘根错节。当时我接下这个需求的时候,信心满满,心想这不过是个简单的 UI 组件而已,两天搞定没问题。结果没想到,它成了我这一年遇到最让我头疼、但也最有收获的一个小项目。

这篇文章我会以第一人称分享我当时是如何一步步从看似简单的功能中抽丝剥茧,最终实现了一个可复用、易维护、性能良好的组件的全过程。希望你也能从中体会到一点真实项目的复杂性以及我们在技术实践中需要注意的一些细节。


问题描述:滑动不流畅、内存飙高、代码膨胀

问题描述:滑动不流畅、内存飙高、代码膨胀

先说清楚问题。我们的目标是让推荐商品以横向滚动的方式展示出来,支持懒加载、预加载,甚至可能后续要加动画效果或者分组功能。

但是实际做的时候出现了几个明显的问题:

  1. 滑动卡顿:在低端设备上(比如 iPhone SE 第一代)滑动时帧率明显下降,特别是在快速滑动时;
  2. 内存上涨厉害:由于推荐数据量大(一页最多返回 20 条),每个 cell 包含图片和文本,如果同时创建太多 cell,容易导致内存暴涨;
  3. 代码难以复用:UICollectionView 实现虽然灵活,但代码结构很容易变得臃肿,尤其是当需要嵌套在复杂的 ViewController 中时;
  4. 布局样式变更频繁:产品经常改设计,有时候要一行一列,有时候两列,有时候还要带 header 或 footer;
  5. 缺乏统一标准:团队中不同人写法差异很大,有的直接 addSubview 到主界面里,有的用自定义 collectionView;后来接手的人根本不知道怎么改。

当时我觉得不能再这么将就下去了,必须好好规划一下这个组件的设计思路和技术选型。


技术选型与方案对比

技术选型与方案对比

1. UICollectionView vs UIStackView + UIScrollView?

首先想到的是,除了 UICollectionView 横向列表之外,还有没有其他更轻量级的方案,比如用 UIStackView + UIScrollView?

  • 优点
    • StackView 布局简单直观,适合数据量不大、静态展示的场景。
  • 缺点
    • 数据量一大就会导致所有 subview 都被加载进内存;
    • 手动管理 contentSize 和子视图回收非常麻烦;
    • 性能不如 UICollectionView 内置的复用机制。

所以这条路直接放弃。

2. UICollectionView + 自定义 FlowLayout vs Compositional Layout?

UICollectionView 的好处是天生支持 cell 复用和滚动优化,但传统 flow layout 可能不能满足多样化的布局需求,这时候很多人会选择 Apple 在 WWDC 2019 推出的 UICollectionViewCompositionalLayout

对比维度 UICollectionViewFlowLayout CompositionalLayout
灵活性 固定行列,扩展性弱 支持多种布局方式,自由组合
开发难度 容易上手 学习曲线稍陡
性能 一般 更优,Apple 设计时就考虑了性能
兼容性 支持 iOS 6+ 最低支持 iOS 13,如果要考虑老系统就得慎重

但我们 App 目前最低系统要求已经是 iOS 14 了,所以兼容性不是大问题。于是我们决定尝试 CompositionalLayout。


实践过程:从布局搭建到性能调优

接下来就是真正的落地过程。

Step 1:构建基础布局结构

我们先封装好一个 HorizontalProductCollectionView,作为独立的 UIView 子类,对外只暴露一个 configure(with items: [Product]) 方法。

关键步骤如下:

func createHorizontalLayout() -> UICollectionViewLayout {
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                          heightDimension: .fractionalHeight(1.0))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    
    let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(120), // 每个卡片宽度
                                           heightDimension: .absolute(200)) // 整体高度固定
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 1)
    
    let section = NSCollectionLayoutSection(group: group)
    section.interGroupSpacing = 12
    section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)
    section.orthogonalScrollingBehavior = .continuous
    
    return UICollectionViewCompositionalLayout(section: section)
}

这里有几个关键点值得说明:

  • 设置每个 cell 占满整个 group;
  • group 的 size 是固定的,这样可以控制卡片大小一致;
  • 正确设置 interGroupSpacing 控制间距;
  • orthogonalScrollingBehavior 设置为 .continuous 让它横向自动对齐卡片(类似于分页效果但又不是真正分页);

这段代码本身并不复杂,但它是整个布局的关键骨架。我们可以把它封装成一个通用方法,用于不同业务场景。

Step 2:Cell 复用 & 图片懒加载

图片懒加载方面,我们当时已经用着 SDWebImage,所以只需要在 prepareForReuse 方法中取消之前的加载任务,并重新配置:

override func prepareForReuse() {
    super.prepareForReuse()
    productImageView.sd_cancelCurrentImageLoad()
    productImageView.image = nil
}

对于数据绑定也进行了简单的 ViewModel 层封装,避免直接把 Model 传给 Cell:

class ProductCell: UICollectionViewCell {
    var viewModel: ProductViewModel? {
        didSet {
            guard let viewModel = viewModel else { return }
            titleLabel.text = viewModel.title
            priceLabel.text = viewModel.price
            productImageView.sd_setImage(with: viewModel.imageUrl)
        }
    }
}

这样不仅提高了组件复用性,也让后续换掉 SDWebImage 成 ImageIO 或者 Nuke 之类更容易。

Step 3:增加预加载策略

为了进一步提升滑动体验,我们在 view controller 中注册了 UICollectionViewDataSourcePrefetching

collectionView.prefetchDataSource = self

然后在回调中提前请求网络资源:

func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
    for indexPath in indexPaths {
        let product = products[indexPath.row]
        if let url = product.imageUrl {
            SDWebImagePrefetcher.shared().prefetchURLs([url])
        }
    }
}

这样一来,用户还没滑到那个 cell 的时候,图片就已经在后台缓存好了,大大提升了首次显示的流畅度。


踩过的坑与解决方案

坑 1:UICollectionView 无法正确计算 contentSize

一开始我们在自定义 container view 里嵌入 collectionView 时,总是发现 collectionView 的 contentSize 不对,导致整体高度撑不开。

解决方式:手动监听 collectionView 的 contentSize 并更新自己:

private var observer: NSKeyValueObservation?

observer = collectionView.observe(\.contentSize, options: [.new]) { [weak self] _, change in
    guard let self = self else { return }
    self.invalidateIntrinsicContentSize()
}

override var intrinsicContentSize: CGSize {
    return collectionView.contentSize
}

然后别忘了在 layoutSubviews 中强制刷新:

override func layoutSubviews() {
    super.layoutSubviews()
    collectionView.frame = bounds
}

坑 2:UICollectionView 重用崩溃

有一次我们测试同学反馈点击某个推荐商品后 crash,堆栈显示是 “cell is nil”。查了一下是在 dequeueReusableCell 的时候没做好安全判断:

错误写法:

let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! ProductCell

建议写法(加上可选绑定):

guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as? ProductCell else {
    return UICollectionViewCell()
}

虽然这种情况很少见,但如果出现注册失败或 Identifier 错误的情况,就能及时兜底。

坑 3:布局样式频繁变更带来的维护成本

之前提到过一个问题,设计师改稿特别勤快,导致每次布局都要重写 layout 的配置代码,很麻烦。

于是我们做了一层适配器抽象,类似:

enum HorizontalViewStyle {
    case cardWithPadding
    case listWithoutTitle
    case gridTwoColumns
}

func configure(style: HorizontalViewStyle) {
    switch style {
    case .cardWithPadding:
        collectionView.collectionViewLayout = createCardLayout()
    case .listWithoutTitle:
        collectionView.collectionViewLayout = createListLayout()
    case .gridTwoColumns:
        collectionView.collectionViewLayout = createGridLayout()
    }
}

这样一来,无论产品怎么变,只要提前准备好几种通用 layout 方式,就可以轻松切换,节省大量时间。


最终效果与收益

做完这些改造之后,我们在以下几个方面取得了显著的收益:

  1. 性能提升:低端设备上的平均帧率从 40FPS 提升到 55FPS 左右;
  2. 内存占用降低:由原来最高可达 400MB 减少到 220MB 左右;
  3. 代码质量提升:原本分散在多个地方的 collectionView 初始化代码被统一收拢到一个组件中;
  4. 可维护性增强:通过配置化 layout 和统一的数据源管理,后续修改只需要改几行配置即可;
  5. 复用性提高:多个业务模块都可以复用这个横向组件,减少了重复开发。

更重要的是,当我们把这个组件封装成 Pod 发布到内部私有仓库之后,其他团队也开始使用它,大家反馈都说:“这东西真香”。


一些经验和建议

如果你也在做类似的横向滚动展示,或者想在项目中引入 compositional layout,我想给你几点经验建议:

  1. 优先使用 CompositionalLayout:它不仅能让你写出更具扩展性的布局,还能享受 Apple 为你优化好的性能特性;
  2. 合理利用 Prefetching:别等到用户滑到了再加载图片,提前缓存能极大提升用户体验;
  3. 注意 collectionView 的生命周期:尤其是 removeFromSuperview 的时候,记得移除观察者或者清理不必要的资源;
  4. 不要过度封装:虽然我们要追求组件化,但也不能为了封装而封装。要根据业务需求取舍;
  5. 预留未来拓展空间:比如留出 delegate、data source 接口,方便外部监听事件;
  6. 持续关注 Apple 新特性:比如 iOS 17 引入的 ScrollableGraphicalElements、LazyVStack/ScrollViewReader 这些概念其实在 iOS 的 UIKit 体系下也可以找到对应的优化空间;
  7. 多做性能调试:可以用 Xcode 的 Instruments 查看内存分配、GPU 使用情况等,避免盲目优化;
  8. 建立统一规范:哪怕是一个小小的 collectionView,也要和团队达成一致的编码风格和结构组织,避免后期“谁写的谁修”的尴尬局面。

后记:一次“小”功能的大反思

其实很多时候,我们会低估一个看似简单的功能背后可能涉及的复杂性和挑战。这次只是一个小小的横向滚动推荐组件,却让我意识到,作为 iOS 工程师,不仅要懂 API,会写代码,更要懂得权衡利弊,做出合适的技术选型。

技术从来不是非黑即白的选择题,而是要在特定的业务场景中,结合团队能力、历史包袱和未来规划,做出最适合当下的一套实践。

希望你能从我的实战经历中学到一点点东西,或者至少感受到:即使是老生常谈的 UICollectionView,也能玩出不一样的花样来。

当然,如果你有更好的做法,我也非常欢迎留言交流 😊

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝