接手Uniapp的App端项目之后你会发现真正让人头疼的不是页面写不出来而是缓存、加载、刷新这三件事没理顺。功能上线第一版真机一跑就原形毕露列表页每次进入都白屏转圈、缓存数据串到别的用户头上、下拉刷新拉了半天页面纹丝不动。这些问题十有八九不是后端接口的锅是本地数据链路出了问题。这篇文章我想把Uniapp在App端最常见的缓存、加载、刷新机制完整拆开讲一遍从存储选型到生命周期时机从下拉刷新的完整配置链路到缓存失效的排查方法尽量给出一份可以直接照着改的参考。适合正在做App端开发、或者刚把H5项目迁到App端的朋友也适合那些被“页面数据不更新”“缓存总是脏的”折磨到怀疑人生的兄弟。1. 缓存机制App端数据到底该放在哪一层1.1 先分清三层存储内存、Storage和文件系统很多朋友一说到缓存就想用uni.setStorageSync这没错但App端的缓存不是只有这一种。按数据特征分至少应该拆成三层模块级内存变量、Storage持久化存储、文件系统存储。三层各有各的适用场景混着用才对。内存变量是最快的页面里定义个let或者说模块里的一个对象读起来就是纯内存操作几乎没有耗时。但它只在App运行的这段时间存在冷启动后就没有了适合放页面状态、频繁切换的数据、临时计算结果这类不要求持久的东西。Storage是Uniapp提供的基础缓存API底层在App端走的是原生存储能力相比H5的localStorage容量和稳定性都更好一些但同样不是无限空间。它适合放“下一次启动App还要用到”的数据比如登录态、用户信息、首页列表的副本。不过它有一个绕不开的点同步读写。setStorageSync和getStorageSync都是同步API会卡住JS线程写大对象或频繁读写的时候页面上会感受到明显的卡顿。文件系统适合处理图片、视频、音频这类大体积资源。比如一个商品详情的banner图每次进入页面都从网络重新拉既费流量又慢下载一次之后把文件持久化保存下次直接用本地路径渲染体验完全不一样。表格对比一下更直观存储层读写速度持久性容量适合场景模块内变量最快不持久内存决定页面状态、会话级数据uni Storage中等同步阻塞持久有上限登录态、启动必需的小数据文件系统取决于磁盘持久大图片、视频、离线包选型的逻辑很简单数据要不要跨App启动保留保留就上Storage不保留优先放内存。数据体量大不大体量大就算要持久也不要塞Storage直接写文件。1.2 Storage缓存封装Key命名、序列化与过期策略裸用uni.setStorageSync是能跑但项目一复杂就出问题。最常见的就是Key冲突和脏数据。你在这个页面存了userInfo那个页面存了userInfo两个业务混在一起后者一写前者就没了。我一般给缓存Key加三层前缀业务前缀 版本号 维度标识。版本号很重要后面会细说。比如const CACHE_PREFIX app_cache_ const CACHE_VERSION v1 export function buildKey(key) { return ${CACHE_PREFIX}${CACHE_VERSION}_${key}; }这样所有缓存Key都带上了全局唯一的命名空间。版本号的意义在于未来你改了缓存数据结构只要把CACHE_VERSION改成v2所有的旧缓存自动“失效”避免老数据被当成新数据用。Storage还有一个细节要注意它底层存储的是字符串。你存一个对象进去Uniapp会自动帮你序列化但如果对象里有函数、循环引用或者undefined写入就可能失败或读出来是空的。所以写入前尽量保证数据可序列化。更推荐的做法是给缓存加过期时间。很多开发者的缓存是一存到底用户改了资料之后App里还显示三天前的数据。解决办法很简单存入的时候把过期时间也写进去export function setCache(key, data, expireSeconds 300) { const wrapper { data, expireAt: Date.now() expireSeconds * 1000 }; try { uni.setStorageSync(buildKey(key), wrapper); } catch (error) { console.error(setCache error, error); } } export function getCache(key) { try { const wrapper uni.getStorageSync(buildKey(key)); if (!wrapper || !wrapper.expireAt) return null; if (Date.now() wrapper.expireAt) { uni.removeStorageSync(buildKey(key)); return null; } return wrapper.data; } catch (error) { console.error(getCache error, error); return null; } }这个封装每次读缓存前都会检查expireAt过期了直接删掉返回null。这样即使你忘记给某个接口单独加刷新逻辑缓存也不会无限期地“赖”在用户手机上。注意setStorageSync是同步操作别在页面频繁滚动或者动画过程里反复调用。宁愿在内存里先攒着到关键节点比如页面隐藏、App进入后台时再批量落盘。1.3 图片与文件缓存网络资源怎么落地成永久文件Uniapp页面里用img或image标签加载网络图片App端底层是有内存缓存和磁盘缓存的。图片第一次加载后有二次加载会快一些但这个过程开发者不可控。如果你有一批热点图片比如首页轮播、商品主图想稳定地保证加载速度最好自己把文件缓存到本地。做法是先用uni.downloadFile下载到临时目录再调用uni.saveFile把临时文件转成持久化文件uni.downloadFile({ url: https://example.com/banner.jpg, success(res) { if (res.statusCode 200) { uni.saveFile({ tempFilePath: res.tempFilePath, success(saveRes) { // saveRes.savedFilePath 就是持久化的本地路径 console.log(saveRes.savedFilePath); } }); } } });先说清楚tempFilePath指向临时目录系统可能会在某些时机清理临时目录。saveFile之后返回的savedFilePath才是真正安全的地方App重启之后依然存在。我自己踩过这个坑第一版只缓存了tempFilePath结果用户第二天打开App图片路径全部失效页面一片空白。把缓存落盘改成saveFile之后才彻底解决。文件缓存需要自己管理生命周期。旧文件不会自动删除建议按时间或者数量做简单淘汰比如每次图片请求时检查同一目录下的文件数量超过一定数量就把最早的文件删掉。没有这一层处理App的体积会被缓存越撑越大。2. 加载机制把页面请求时机和渲染时机调顺2.1 onLoad、onShow、onReady到底选哪个发请求Uniapp的页面生命周期跟原生小程序类似App端也同样适用。三个生命周期各有各的触发时机选错了就会遇到“页面空一下才出内容”或者“返回上一页后数据不刷新”这种体验问题。表格先看一下差异生命周期触发时机触发次数适合干什么onLoad页面初次加载仅一次初始化数据请求、读取参数onShow页面显示时每次进入都会触发前置页面返回后的刷新、状态同步onReady页面渲染完成仅一次操作组件的DOM或调用绘图API首屏数据请求放在onLoad里是最常规的操作尤其是详情页这类只需要加载一次数据的场景。但列表页不一样用户从列表页跳到详情页再返回列表页onLoad不会重新触发列表还停留在原来的位置和数据上。如果这个列表的数据会变化比如下单后库存变了就要靠onShow来做“回来刷新”的判断。顺序上有个细节onLoad先触发然后onShow触发最后onReady。所以在onLoad里发请求是早于页面渲染的数据回来时页面刚好渲染完体验最顺。如果把请求放到onReady里发起就会白白多等一次渲染时间。onLoad() { this.fetchList(); }, onShow() { // 从详情页返回时通过一个标记判断是否需要刷新 if (this.needRefresh) { this.fetchList(); this.needRefresh false; } }这里的关键是不要两个生命周期里重复发请求。onLoad发一次的onShow就要有个条件兜底不然每次进入页面都连发两次请求接口被打爆用户流量也白白浪费。2.2 三种数据加载模式直连、缓存优先和预加载我做App端数据请求一般会在封装层区分三种模式直接请求、缓存优先、预加载传入。三种模式对应三种业务场景选错了体验差距很大。直接请求是最简单的进入页面就uni.request数据回来再渲染。适合实时性要求极高的场景比如订单状态、支付结果。但它的体验也是最差的网络慢的时候页面一直在转圈用户可能直接退出了。缓存优先适合那些内容不会频繁变化、或者对实时性不敏感的数据比如首页公告、商品详情、文章内容。核心思路是进入页面先读本地缓存直接渲染同时后台发请求接口返回后再用新数据覆盖缓存和页面。用户看起来就是秒开体验非常顺。我通常封装一个requestWithCache统一处理缓存优先逻辑export function requestWithCache(options, { useCache true, expireSeconds 300 } {}) { const cacheKey options.cacheKey || ${options.url}_${JSON.stringify(options.data || )}; if (useCache) { const cached getCache(cacheKey); if (cached) { return Promise.resolve(cached); } } return new Promise((resolve, reject) { uni.request({ ...options, success(res) { if (res.statusCode 200) { setCache(cacheKey, res.data, expireSeconds); resolve(res.data); } else { reject(res); } }, fail: reject }); }); }这个封装已经把上一节讲的缓存读写都串起来了。调用方只需要决定要不要用缓存以及缓存多长时间// 实时数据 requestWithCache({ url: /api/order/status }, { useCache: false }); // 商品详情5分钟缓存 requestWithCache({ url: /api/goods/detail, data: { id } }, { expireSeconds: 300 });第三种是预加载。用户在列表页的时候就提前把详情页的数据请求好放到一个全局变量或者通过事件通道传到详情页。详情页打开时直接在onLoad里拿现成数据渲染请求都不用发。适合那种“结果基本能预测到”的页面跳转比如从列表进入下一条详情。代价是如果用户根本不点进去预加载的请求就白发了所以要挑点击概率高的场景用。2.3 分包加载和组件懒加载别让首页扛下所有很多Uniapp项目最后卡在启动速度上根本原因是主包太大了。所有页面、所有组件、所有静态资源全部塞在主包里App冷启动要解析和加载的东西就非常多白屏时间自然长。解决办法是合理分包。App端分包规则跟微信小程序类似在pages.json里配置subPackages{ pages: [ { path: pages/index/index, style: { navigationBarTitleText: 首页 } } ], subPackages: [ { root: pages/order, pages: [ order/list, order/detail ] } ] }把低频业务页面放到分包里用户不访问这个模块的时候对应代码不会参与启动加载。首页启动就只需要加载主包的页面和公共组件速度提升非常明显。组件层同理。一个页面里如果挂了几十个自定义组件渲染压力很大。用v-if把非首屏组件先藏起来等滚动到可视区域或者某个操作发生后再创建。最典型的就是页面底部的内容用户首屏根本看不到等滚动过去再渲染也不迟。2.4 加载状态管理骨架屏和并发请求页面加载过程中转圈loading是最常见的做法但体验一般。用户看到的是一个空白页加一个小菊花根本不知道页面在干什么。相比之下骨架屏就好很多它是按照页面最终大致结构渲染出灰色块用户视觉上会感觉页面“正在成形”等待的焦虑感低很多。Uniapp里实现骨架屏不复杂页面里加一个骨架屏组件数据加载完成后用v-if切回真实内容view v-ifloading classskeleton view classskeleton-line/view view classskeleton-row/view /view view v-else !-- 真实内容 -- /view并发请求的体验也要统一管理。一个页面同时要拉用户信息、列表、配置如果每个请求都各自set loading就会互相覆盖。正确的是用Promise.all合并请求等所有数据都回来了再一次性关闭loadingonLoad() { this.loading true; Promise.all([ this.fetchUserInfo(), this.fetchList(), this.fetchConfig() ]).finally(() { this.loading false; }); }这样用户看到的就是一次loading而不是页面闪几下。3. 刷新机制数据更新后如何让页面跟上节奏3.1 下拉刷新从配置到停下来的完整链路Uniapp的下拉刷新不是页面里加一行代码就完事它是一条完整的链路pages.json配置开关 页面onPullDownRefresh生命周期 刷新方法 手动停止刷新动画。缺了任何一环表现都是“拉不动”或者“拉下来了不回去”。先说配置。在pages.json里对应页面的style中开启enablePullDownRefresh{ pages: [ { path: pages/index/index, style: { enablePullDownRefresh: true, navigationBarTitleText: 首页 } } ] }页面代码里实现对应的生命周期onPullDownRefresh() { // 下拉刷新必须强制跳过缓存 this.fetchList(true).finally(() { uni.stopPullDownRefresh(); }); }这里有个非常重要的原则刷新请求一定要绕过缓存。不然你下拉刷新的动作是触发了但请求走的是缓存优先逻辑返回的还是本地旧数据界面当然纹丝不动。我见过不少项目下拉刷新做了拿到的是缓存数据用户以为刷新没用其实是请求层没有区分“首次加载”和“手动刷新”。建议给请求方法加一个force参数async fetchList(force false) { const data await requestWithCache( { url: /api/list, method: GET, data: { page: this.page, pageSize: this.pageSize } }, { useCache: !force, expireSeconds: 60 } ); this.list data.list; }force为true时useCache自动变成false重新请求网络数据然后写缓存。首次加载时force为false走缓存优先。一次封装两个场景都照顾到了。stopPullDownRefresh必须在数据请求完成后调用而且要放在finally里。如果接口报错你也要让下拉动画结束不然用户就会看到一个卡在半路的下拉状态非常怪异。3.2 上拉加载更多分页参数与状态守卫上拉加载更多在Uniapp里对应的生命周期是onReachBottom页面滚动到底部时自动触发。做分页需要三个状态当前页码page、是否正在加载loading、是否没有更多noMore。data() { return { list: [], page: 1, pageSize: 20, loading: false, noMore: false }; }, onReachBottom() { if (this.loading || this.noMore) { return; } this.loading true; this.page; this.fetchList() .finally(() { this.loading false; }); }, methods: { async fetchList() { const data await requestWithCache({ url: /api/list, data: { page: this.page, pageSize: this.pageSize } }, { useCache: false }); if (data.list.length this.pageSize) { this.noMore true; } // 第一页直接覆盖后续页追加 if (this.page 1) { this.list data.list; } else { this.list this.list.concat(data.list); } } }loading和noMore这两个守卫很关键。onReachBottom在用户快速滚动时有可能连续触发没有loading判断就会出现重复请求、列表重复数据。noMore的判断则避免在没数据的时候还不停发请求浪费流量和接口资源。有个细节容易踩坑页面内容不满一屏时onReachBottom可能不会触发。比如第一页只有5条数据远不够撑满整个屏幕用户上滑后页面根本没有滚动上拉加载事件自然不触发。解决办法是保证页面可以滚动或者在onReady里检查数据量是否够撑满一屏不够就手动再加载一页。如果你用的不是页面的滚动而是自定义的scroll-view组件那onReachBottom就不管用了要在scroll-view上监听scrolltolowerscroll-view scrolltoloweronReachBottom :scroll-ytrue ... /scroll-view3.3 业务刷新信号事件总线和全局状态下拉刷新和上拉加载解决的是用户主动触发的刷新但很多业务场景是页面外的数据变了页面被动需要刷新。比如下单成功后返回列表页列表要立刻展示新状态用户修改头像后所有页面的头像都要跟着变。这类需求靠用户手动下拉刷新肯定不行得用代码主动发信号。Uniapp内置了事件总线uni.$emit和uni.$on在App端可以直接用。下单成功后发一个事件uni.$emit(orderUpdated, { orderId: this.orderId });列表页在onLoad里注册监听onLoad() { this.handleOrderUpdated (data) { // 重新拉取数据 this.fetchList(true); }; uni.$on(orderUpdated, this.handleOrderUpdated); }用事件总线最大的坑是忘记清理监听。如果你的$on是写在onLoad里的页面销毁时onUnload里一定要$offonUnload() { uni.$off(orderUpdated, this.handleOrderUpdated); }注意$off的时候要把回调函数传进去不能直接uni.$off(orderUpdated)那样会把事件名字相同的所有监听全部移除其他页面注册的同名监听也会被误删。如果项目用了vuex或者pinia这类全局状态管理刷新逻辑还可以下沉到store的action里。页面只负责调用actionaction内部决定是重新请求还是先修改本地状态再同步服务器。这种方式的优点是多页面刷新逻辑收敛在一起排查问题好定位。3.4 缓存、加载、刷新三者的闭环配合这三件事其实不能分开看它们是一条完整的数据闭环先读缓存出界面同时发请求请求成功后写缓存并更新界面手动刷新时跳过缓存强拉新数据版本升级时整体废弃旧缓存。我自己的标准封装流程是这样的页面onLoad先getCache有缓存立刻渲染再调requestWithCache发请求最新数据回来后setCache并更新界面用户下拉刷新时force设为true跳过缓存直接请求发版后如果缓存结构变了CACHE_VERSION加一旧缓存自然不用。这样用户五秒钟内看到的一定有内容因为缓存几乎是秒出一秒钟内大概率看到最新数据因为网络请求很快回来了。不管网络慢还是后端挂了页面都不会白屏因为缓存兜底了。这看起来很简单但它是整个App体验的底层逻辑。很多藏在深处的“偶现bug”都是这里没闭环好有的项目只写缓存不读缓存有的项目只读缓存不刷新有的项目刷新了不写缓存最后都会以某种“不可复现”的奇怪问题浮现出来。4. 常见问题排查与避坑实录4.1 缓存串号和脏数据为什么A用户看到了B用户的数据这个问题我最初排查的时候也花了很久。现象是用户A登录之后首页显示的却是上一个用户B的收藏记录退出登录再切换账号也没用。原因基本都出在缓存Key设计上没有区分用户维度。比如缓存Key写死是app_cache_v1_favorite_listA用户登录时写了缓存B用户登录时本来应该重新读取但getCache还是优先读到了旧缓存直接渲染出来了。解决办法是缓存Key里带上用户ID和业务维度const cacheKey ${userId}_favorite_list; requestWithCache({ url: /api/favorite/list, cacheKey });顺便提醒一句登录退出时要主动清理用户相关缓存。可以在退出登录的方法里遍历删除用户维度前缀的Key或者直接调用uni.clearStorageSync()。但如果App里还有不需要清掉的公共配置缓存就别用全量清理按前缀精准删除更合适。4.2 下拉刷新失效和不触发三种常见情况下拉刷新失效是最容易被误判的。先检查三件事第一pages.json里是否真的开了enablePullDownRefresh。这个配置是页面级的有时候你改的是全局window配置页面自身没生效下拉自然没有反应。第二页面是不是用了scroll-view。之前讲过自定义滚动区域会接管页面滚动此时下拉刷新的触发机制就变了。使用scroll-view时应该改用它自己的refresher相关属性或者干脆让页面本身保持滚动。第三刷新请求是否走了缓存。这是最隐蔽的页面配置没问题下拉动画也能触发但数据一动不动。原因就是请求层useCache没有区分刷新场景还是返回了缓存。刷新时强制让useCache为false基本就不会再有这种“假刷新”了。4.3 Storage写入失败和同步阻塞异常都藏在细节里很多项目会在特定机型上出现“数据保存不了”的报告。跟踪下来发现是setStorageSync抛异常了但代码里没有try/catch直接导致上层逻辑中断。Storage写入失败的常见原因有几种存入了非序列化对象、单条数据体量过大、存储空间被占满。虽然App端容量比H5宽裕但也不是无限的塞太多图片Base64字符串迟早爆掉。我的建议是所有setCache和getCache封装内部都加try/catch坏数据不要影响业务主流程大字符串图片Base64、长日志不要进Storage落文件系统写入频率做节流不要在滚动过程里反复写。Storage同步阻塞的问题则靠“能不写就不写、能批量就批量”来缓解。用户操作一次页面可以在内存里先累积写入标记等到页面onHide或者App的onHide里再一次性落盘对流畅度的影响会小很多。4.4 事件刷新丢失和重复触发onUnload清理要到位事件总线用起来很方便但很多代码写到最后都会出现两个问题事件不触发或者触发了很多次每次都执行一遍。事件不触发大概率是注册时机和发布时机错位了。比如在onLoad里注册$on但某个业务在onLoad之前就把$emit发出去了监听还没挂上消息就丢了。解决办法是注册尽量提前或者在页面onLoad时提供一个“主动拉去最新状态”的动作不能单纯依赖事件。重复触发则基本是监听没有清理。用了匿名函数$on想在onUnload里通过$off清除发现根本清不掉因为回调函数的引用对不上。所以注册的时候一定把回调存到this上清理时传入同一个函数引用。还有一个隐蔽点App端页面栈里存在多个页面实例时如果每个都注册了同一个事件监听$emit一次就会触发多次。靠$off清理是唯一出路别偷懒。4.5 常见问题速查表把这个项目里最容易遇到的几个问题整理成一个表排查的时候对着看能省不少时间现象常见原因排查方向解决方法进入页面白屏没有缓存兜底断网状态下打开页面数据接入getCache缓存优先缓存数据串用户Key缺少用户维度切换账号后对比缓存KeyKey拼接userId下拉刷新无反应pages.json没开配置查看页面style配置enablePullDownRefresh置为true下拉刷新动画卡住stopPullDownRefresh没调用看finally里是否调用放finally保证总会停止上拉不触发页面内容不满一屏检查onReachBottom日志手动补加载或改用scroll-view监听列表重复渲染上拉缺少loading守卫快速滚动时看请求次数加loading判断请求完成前不再触发事件触发多次监听未正确清理检查onUnload里的$off保存回调引用并在销毁时清理图片本地路径失效用了tempFilePath重启App验证路径改用uni.saveFile持久化路径版本更新后数据异常旧缓存结构不兼容对比缓存数据和代码结构缓存Key加版本号统一失效这些坑我基本都在真实项目里踩过每一个都对应着一段调试时间。能把它们提前规避掉后面的维护工作会轻松很多。最后说一个我自己的习惯缓存、加载、刷新这三件事不要各自为政最好封装成一套统一的数据层抽象。我见过太多项目缓存逻辑散落在页面各处刷新代码到处复制请求层三层五层各不相同结果后端改一个字段全项目要大扫除。趁项目还小把这条数据闭环梳理清楚后面省下的时间远比写这些基础代码的时间多。这也是我为什么把这三个机制放在一起讲——它们本来就是一体的。