
一. 引言
在真实业务中,一个页面往往需要同时请求多个接口。
Banner、推荐内容、直播列表、赛事信息......随着产品功能的不断演进,多接口页面几乎成为常态。
当接口数量逐渐增多时,页面加载体验也开始出现问题。
这篇文章记录一次真实项目中,多接口页面的请求与渲染优化实践,重点不在"接口如何并发",而在如何在不牺牲页面稳定性的前提下,让内容尽早可用。
二. 背景**:并发请求之后,页面依然加载很慢**
当前这个页面需要同时请求 8 个接口:
- Banner数据
- 模型列表
- 知名主播
- 明星主播
- 热门直播
- 分类直播
- 热门赛事
- 推荐方案
最初的实现方式是:8 个接口同时并发请求,等所有接口返回,再统一刷新页面。
从实现上看,这已经是一个"标准解法"。但在实际使用中,体验问题依然明显
- 只要某一个接口返回较慢
- 整个页面就会长时间处于 loading 状态
- 已经返回的数据无法被及时展示
并发请求并没有带来预期中的加载体验提升。
三. 问题本质:刷新时机
进一步分析后发现,真正影响体验的并不是接口数量,而是页面的渲染策略。
在当前的实现中:页面的刷新时机被最慢的接口决定了,而快的接口返回接口在此之前无法产生任何价值。
从用户视角来看:页面什么时候"可用",并不取决于接口是否并发,而取决于什么时候开始看到有意义的内容。
这也会成为后续优化方案的出发点。
四. 问题解决方案
为了解决这个问题,我们可以采取两步办法:
- 第一步:基于页面模块的受控接口分组刷新
- 第二步:非强实时数据的本地缓存
我们接下来就分别来讲解一下这两个方案。
4.1 基于页面模块的受控接口分组刷新
4.1.1 为什么不能"一个接口刷新一次"
在多接口页面中,一个常见的优化思路是:每一个接口完成后,就刷新一次页面。
这种方式虽然可以让数据尽早展示,但在真实页面中往往会带来新的问题:
- 页面频繁刷新,产生明显跳动
- 列表反复重排,影响滚动和点击体验
- 数据尚未完整时提前展示,降低模块可读性
因此,在当前页面中,并没有选择"接口级刷新",而是采用了受控的接口分组刷新策略。
4.1.2 分组原则:速度与稳定性的平衡
接口分组并不是随意的,主要遵循以下原则:
- UI 上属于同一模块
- 业务数据强关联
- 作为一个整体展示才有意义
基于这些原则,将原本的 8 个接口拆分为 6 组:
- Banner数据
- 模型列表
- 知名专家 + 明星主播
- 热门直播 + 分类直播
- 热门赛事
- 推荐方案
专家与主播数据在页面中共同构成一个推荐区域,分类直播是热门直播的补充信息,二者强关联。
每一组接口只会触发一次页面刷新。
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 缓存的前提:是否依赖实时性
并不是所有接口都适合缓存。
最终选择对以下模块进行本地缓存:
- Banner
- 模型
- 知名专家 + 明星主播
- 热门直播 + 分类直播
- 热门赛事
这些数据的共同特点是:
- 实时性要求相对较低
- 即使略有延迟,对用户体验影响也不大
而"推荐方案"则保持实时请求,以避免状态错乱。
4.2.2 缓存实现
在本次优化中,并没有引入数据库或复杂的对象缓存,而是采用了一种更轻量的方式:缓存接口返回的原始 JSON 文件。
之所以选择这种方案,核心原因在于:首页相关接口的数据结构相对稳定,但字段并非完全固定,如果直接缓存 Model 对象,不仅侵入性强,还会引入额外的维护成本。
因此,这里选择绕开 Model 层,只缓存网络层返回的 Dictionary / Array 形式的原始 JSON 数据。
缓存工具类的职责非常单一,只做两件事:
- 将接口返回的 Dictionary / Array 原样序列化为 JSON 并写入本地文件
- 在需要时 从本地文件中读取 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 可能会被调用两次:
- 第一次:命中缓存 → 快速渲染页面
- 第二次:网络返回 → 覆盖缓存并刷新模块
这是一个有意设计的行为,而不是 Bug。
五. 结语
回过头来看,这次多接口页面的优化,并没有引入复杂的架构,也没有追求"接口级"的极致刷新。
更多是在实际产品形态和用户体验之间,做了一些相对克制的取舍。
在这个页面中,真正重要的并不是接口是否并发,而是:
- 哪些数据值得等
- 哪些数据不值得等
- 页面什么时候开始"对用户有意义"
因此,最终选择的是:
- 以页面模块为单位,而不是以接口为单位进行刷新
- 对强关联数据进行整体请求和整体展示,避免页面频繁跳动
- 对非强实时数据引入轻量级的本地缓存,让页面尽早有内容可看
这些选择并不一定适用于所有多接口页面,但在当前这个场景下,它们很好地平衡了加载速度、页面稳定性和实现复杂度。
多接口页面的优化,本质上不是技术能力的堆叠,而是对页面结构和数据特性的理解。
当我们开始从"用户什么时候能看到内容"出发,而不是从"接口怎么并发"出发时,往往就已经走在正确的方向上了。