多接口页面的请求与渲染优化:一次基于产品形态的实践

一. 引言

在真实业务中,一个页面往往需要同时请求多个接口。

Banner、推荐内容、直播列表、赛事信息......随着产品功能的不断演进,多接口页面几乎成为常态。

当接口数量逐渐增多时,页面加载体验也开始出现问题。

这篇文章记录一次真实项目中,多接口页面的请求与渲染优化实践,重点不在"接口如何并发",而在如何在不牺牲页面稳定性的前提下,让内容尽早可用。

二. 背景**:并发请求之后,页面依然加载很慢**

当前这个页面需要同时请求 8 个接口:

  1. Banner数据
  2. 模型列表
  3. 知名主播
  4. 明星主播
  5. 热门直播
  6. 分类直播
  7. 热门赛事
  8. 推荐方案

最初的实现方式是:8 个接口同时并发请求,等所有接口返回,再统一刷新页面。

从实现上看,这已经是一个"标准解法"。但在实际使用中,体验问题依然明显

  • 只要某一个接口返回较慢
  • 整个页面就会长时间处于 loading 状态
  • 已经返回的数据无法被及时展示

并发请求并没有带来预期中的加载体验提升。

三. 问题本质:刷新时机

进一步分析后发现,真正影响体验的并不是接口数量,而是页面的渲染策略。

在当前的实现中:页面的刷新时机被最慢的接口决定了,而快的接口返回接口在此之前无法产生任何价值。

从用户视角来看:页面什么时候"可用",并不取决于接口是否并发,而取决于什么时候开始看到有意义的内容。

这也会成为后续优化方案的出发点。

四. 问题解决方案

为了解决这个问题,我们可以采取两步办法:

  • 第一步:基于页面模块的受控接口分组刷新
  • 第二步:非强实时数据的本地缓存

我们接下来就分别来讲解一下这两个方案。

4.1 基于页面模块的受控接口分组刷新

4.1.1 为什么不能"一个接口刷新一次"

在多接口页面中,一个常见的优化思路是:每一个接口完成后,就刷新一次页面。

这种方式虽然可以让数据尽早展示,但在真实页面中往往会带来新的问题:

  • 页面频繁刷新,产生明显跳动
  • 列表反复重排,影响滚动和点击体验
  • 数据尚未完整时提前展示,降低模块可读性

因此,在当前页面中,并没有选择"接口级刷新",而是采用了受控的接口分组刷新策略。

4.1.2 分组原则:速度与稳定性的平衡

接口分组并不是随意的,主要遵循以下原则:

  • UI 上属于同一模块
  • 业务数据强关联
  • 作为一个整体展示才有意义

基于这些原则,将原本的 8 个接口拆分为 6 组:

  1. Banner数据
  2. 模型列表
  3. 知名专家 + 明星主播
  4. 热门直播 + 分类直播
  5. 热门赛事
  6. 推荐方案

专家与主播数据在页面中共同构成一个推荐区域,分类直播是热门直播的补充信息,二者强关联。

每一组接口只会触发一次页面刷新。

4.1.3 实现示意(模块级刷新)

下面是一个简化后的示意代码,用来说明模块级刷新思路:

Swift 复制代码
func requestHomeData() {

    // Banner
    requestBannerData {
        self.refreshUI()
    }

    // 模型
    requestModelListData {
        self.refreshUI()
    }

    // 专家 + 主播(强关联)
    let expertGroup = DispatchGroup()
    expertGroup.enter()
    requestExpertData {
        expertGroup.leave()
    }
    expertGroup.enter()
    requestStarData {
        expertGroup.leave()
    }
    expertGroup.notify(queue: .main) {
        self.refreshUI()
    }

    // 直播相关(强关联)
    let liveGroup = DispatchGroup()
    liveGroup.enter()
    requestHotLiveData {
        liveGroup.leave()
    }
    liveGroup.enter()
    requestLiveCategoryData {
        liveGroup.leave()
    }
    liveGroup.notify(queue: .main) {
        self.refreshUI()
    }

    // 热门赛事
    requestHotMatchData {
        self.refreshUI()
    }

    // 推荐方案
    requestRecommendListData(page: 1) { _, _ in
        self.refreshUI()
    }
}

需要注意的是:

  • DispatchGroup 的使用粒度是模块级
  • 页面允许被多次刷新,但刷新次数是可控的

4.2 非强实时数据的本地缓存

在解决了接口阻塞问题后,还有一个常见场景需要考虑:冷启动或弱网环境下,页面首次加载依然存在明显空白期。

为此,引入了本地缓存策略。

4.2.1 缓存的前提:是否依赖实时性

并不是所有接口都适合缓存。

最终选择对以下模块进行本地缓存:

  1. Banner
  2. 模型
  3. 知名专家 + 明星主播
  4. 热门直播 + 分类直播
  5. 热门赛事

这些数据的共同特点是:

  • 实时性要求相对较低
  • 即使略有延迟,对用户体验影响也不大

而"推荐方案"则保持实时请求,以避免状态错乱。

4.2.2 缓存实现

在本次优化中,并没有引入数据库或复杂的对象缓存,而是采用了一种更轻量的方式:缓存接口返回的原始 JSON 文件。

之所以选择这种方案,核心原因在于:首页相关接口的数据结构相对稳定,但字段并非完全固定,如果直接缓存 Model 对象,不仅侵入性强,还会引入额外的维护成本。

因此,这里选择绕开 Model 层,只缓存网络层返回的 Dictionary / Array 形式的原始 JSON 数据。

缓存工具类的职责非常单一,只做两件事:

  1. 将接口返回的 Dictionary / Array 原样序列化为 JSON 并写入本地文件
  2. 在需要时 从本地文件中读取 JSON,并原样返回

不关心字段含义,也不参与业务解析。

这种方式带来的好处也非常直接:

  • 不依赖 Model 结构
  • 不受字段增删影响
  • 不需要做版本迁移
  • 出问题时可直接删除缓存文件
  • 调试成本极低(文件可直接查看)

非常适合首页这类"尽早展示内容优先于强一致性"的场景。

缓存工具类实现

下面是本次使用的 JSON 文件缓存工具类实现,整体保持极简:

Swift 复制代码
final class ZMRawJSONFileCache {

    static func fileURL(for fileName: String) -> URL {
        let doc = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
        return doc.appendingPathComponent(fileName)
    }

    /// 保存:Dictionary / Array 原样 JSON
    static func save(jsonObject: Any, fileName: String) {
        guard JSONSerialization.isValidJSONObject(jsonObject) else {
            print(" 非法 JSON,无法缓存:\(fileName)")
            return
        }

        let url = fileURL(for: fileName)
        do {
            let data = try JSONSerialization.data(
                withJSONObject: jsonObject,
                options: [.prettyPrinted]
            )
            try data.write(to: url, options: .atomic)
            print(" JSON 缓存成功:\(fileName)")
        } catch {
            print(" JSON 缓存失败:\(fileName),error: \(error)")
        }
    }

    /// 读取:直接返回 Dictionary / Array
    static func loadJSON(fileName: String) -> Any? {
        let url = fileURL(for: fileName)
        guard FileManager.default.fileExists(atPath: url.path) else {
            print(" 缓存不存在:\(fileName)")
            return nil
        }

        do {
            let data = try Data(contentsOf: url)
            let json = try JSONSerialization.jsonObject(with: data, options: [])
            print(" JSON 缓存读取成功:\(fileName)")
            return json
        } catch {
            print(" JSON 缓存读取失败:\(fileName),error: \(error)")
            return nil
        }
    }
}

这里有几个实现细节值得一提:

  • 缓存位置使用 Documents 目录,而非 UserDefaults
  • 使用 .atomic 写入,避免异常情况下生成损坏文件
  • 不对 JSON 结构做任何假设,只校验其合法性

实际使用方式:先缓存,后网络

以 Banner 接口为例,缓存的使用顺序如下:

Swift 复制代码
func requestBannerData(completion: @escaping ([ZMNewsBannerItemModel]) -> Void) {

    // 1. 优先读取本地缓存
    if let json = ZMRawJSONFileCache.loadJSON(fileName: HomeCacheFile.banner) as? [String: Any] {
        let model = ZMNewsBannerModel(JSON: json)
        self.bannerList = model?.data ?? []
        completion(self.bannerList)
        ZMLogHepler.debug(content: "Banner 使用本地缓存",
                           context: "ZMRecommendPresenter")
    }

    // 2. 同时发起网络请求
    ZMNetWorkManager.request(
        apiPoit: ZMNetWorkNormalAPI.endpoint_home_banner,
        parameters: nil,
        modeType: ZMNewsBannerModel.self
    ) { [weak self] model, data, error in
        guard let self = self else { return }

        if let json = data {
            ZMRawJSONFileCache.save(
                jsonObject: json,
                fileName: HomeCacheFile.banner
            )
            ZMLogHepler.debug(content: "Banner 本地缓存更新",
                               context: "ZMRecommendPresenter")
        }

        self.bannerList = model?.data ?? []
        completion(self.bannerList)
    }
}

需要注意的是,这里的 completion 可能会被调用两次:

  1. 第一次:命中缓存 → 快速渲染页面
  2. 第二次:网络返回 → 覆盖缓存并刷新模块

这是一个有意设计的行为,而不是 Bug。

五. 结语

回过头来看,这次多接口页面的优化,并没有引入复杂的架构,也没有追求"接口级"的极致刷新。

更多是在实际产品形态和用户体验之间,做了一些相对克制的取舍。

在这个页面中,真正重要的并不是接口是否并发,而是:

  • 哪些数据值得等
  • 哪些数据不值得等
  • 页面什么时候开始"对用户有意义"

因此,最终选择的是:

  • 以页面模块为单位,而不是以接口为单位进行刷新
  • 对强关联数据进行整体请求和整体展示,避免页面频繁跳动
  • 对非强实时数据引入轻量级的本地缓存,让页面尽早有内容可看

这些选择并不一定适用于所有多接口页面,但在当前这个场景下,它们很好地平衡了加载速度、页面稳定性和实现复杂度。

多接口页面的优化,本质上不是技术能力的堆叠,而是对页面结构和数据特性的理解。

当我们开始从"用户什么时候能看到内容"出发,而不是从"接口怎么并发"出发时,往往就已经走在正确的方向上了。