简介:面向微信小程序初学者与需要练手项目的开发者,这份火车票查询示例围绕接口调用与页面数据展示展开,可用于理解小程序请求封装、页面路由及基础界面组织,并在此基础上继续扩展车次筛选、站点选择等功能。压缩包共23个文件,以8个js脚本、5个json配置、5个wxss样式和4个wxml页面结构为主,另含1个markdown说明,整体约11KB,体积轻便,便于快速导入开发者工具查看目录与运行逻辑。目前已有1643人学习下载,说明其作为入门参考具有一定关注度。读者可从中获得一套可运行的查询页面骨架、接口请求模块的拆分方式、车次与座位及日志等页面的组织思路,以及配置与样式文件的配套写法,适合用来对照练习小程序项目结构和二次开发。
1. 一趟车没查出来,问题往往不在接口
做票务查询小程序的人多半有过这种误判:以为难点在数据接口,结果真机一跑,列表渲染、站点匹配、日期解析轮着出问题。用户输入「北京」,查不到「北京南」出发的车次;换了个出发站,列表却还停在上一组结果;iOS 上日期直接显示 NaN。这份「微信小程序-火车票查询」资源,把查询链路上最容易出问题的几个环节压成了一套能跑的最小骨架:站点字典与联想输入、车次对象建模、查询时序与竞态处理、余票列表渲染、分页与缓存。它不适合想直接上线卖票的人,适合两类:想快速搭出查询原型的独立开发者,以及页面已经写完、卡在真机表现上的同学。下面按「数据怎么组织 → 请求怎么发 → 列表怎么刷 → 坑在哪」的顺序拆。
2. 骨架先立住:页面职责、数据结构与查询时序
查询类小程序最容易写崩的地方,是页面里什么都干。查询页既做输入联想又发请求还负责渲染列表,代码到三百行就开始互相污染。这套骨架的第一条设计原则是「职责单一」:查询页只管收集参数和校验,列表页只管渲染和分页,详情页只管单趟车的座位展开。数据在页面之间通过 URL 参数和全局缓存传递,不通过事件总线绕来绕去。
2.1 目录结构与页面职责划分
三个页面加一个 utils 目录,是这类项目最省心的粒度。pages/query 是入口,放出发站、到达站、日期三个选择器和查询按钮;pages/list 接收 fromCode、toCode、date 三个参数,负责拉数据、渲染、分页;pages/detail 接收车次 id,展示经停站和完整席别。utils 下拆四块:station-index(站点字典与检索)、api(请求封装与错误归一)、date(日期解析与格式化)、model(接口原始数据到视图数据的转换)。
{ "pages": [ "pages/query/query", "pages/list/list", "pages/detail/detail" ], "window": { "navigationBarTitleText": "车票查询", "navigationBarBackgroundColor": "#ffffff", "backgroundColor": "#f5f6f8" }, "lazyCodeLoading": "requiredComponents" }这段配置里真正影响体验的是最后一行。小程序默认会把所有页面的代码一次性注入,查询页刚打开时列表页的渲染逻辑也在内存里;打开lazyCodeLoading后只注入当前页面用到的自定义组件,冷启动会明显轻一些。注意它只对自定义组件生效,页面 JS 依然全量注入,所以别指望它解决所有启动问题。
职责划分要落到一个硬约束:页面不直接调wx.request。所有网络访问都走 utils/api,页面只面对queryTrains(params)这一个函数,返回 Promise。这样做的好处是错误处理只写一遍——超时、非 200、业务码异常、空列表,四种情况在 api 层归一成统一的{ list, total }或一个带 message 的 Error,页面里只需要一个 catch。
2.2 车次对象与站点字典的数据结构设计
接口返回的车次数据通常字段松散:时间可能是"07:05"字符串,时长可能是"4小时32分",票价可能是字符串数字。直接丢给 WXML 渲染,后面排序、筛选、比较全要额外写转换。更省事的做法是在 model 层一次性转成「视图友好 + 计算友好」的结构,时间全部转成从零点开始的分钟数,时长转成整数分钟,价格转成 Number。
// utils/model.js // 把接口原始车次转成视图对象,所有时间/时长都以「分钟数」存内部值 function toMinute(hhmm) { const [h, m] = String(hhmm).split(':').map(Number); return h * 60 + m; // "07:05" -> 425 } function normalizeTrain(raw) { const departMinute = toMinute(raw.departTime); let arriveMinute = toMinute(raw.arriveTime); let plusDay = 0; if (arriveMinute < departMinute) { // 跨天车次,到达时间小于出发时间 arriveMinute += 24 * 60; plusDay = 1; } return { id: `${raw.trainCode}_${raw.fromCode}_${raw.toCode}_${raw.departDate}`, trainCode: raw.trainCode, // G1234 fromStation: raw.fromStation, toStation: raw.toStation, departMinute, arriveMinute, plusDay, // 0 当天到,1 次日到 durationMinute: arriveMinute - departMinute, seats: (raw.seats || []).map(s => ({ type: s.typeName, // 商务座/一等座/二等座/无座 price: Number(s.price) || 0, remain: Number(s.remain) || 0 // 0 表示无票,但要保留席别用于灰显 })) }; }几个参数值得单独说。id必须是业务唯一键而不是数组下标,后面wx:key和局部 setData 都要靠它定位,用下标会在余票刷新后导致列表错位。departMinute / arriveMinute存分钟数,排序、筛「3 小时内到达」、算跨天全部变成整数运算,比字符串比较省心得多。durationMinute同理,不要存"4小时32分"这种展示串——它没法比较大小,也没法二次格式化。seats里remain为 0 的席别必须保留,否则会出现「这趟车只剩商务座」时整个席别行消失、用户以为没有这趟车的误解,正确做法是灰显加「无票」二字。
站点字典是另一半地基。它不需要很大,覆盖常见站点即可,结构上做成「中文名 → 站点对象」和「拼音/首字母 → 站点对象数组」两张表。为什么拼音表是数组:bj这种首字母会命中北京、北j…多个站,必须保留全部候选让上层做排序。
2.3 查询链路:从输入校验到渲染的时序
真正的坑集中在「用户手快」这件事上。A 同学连着改了三次出发站,三个请求在飞,谁先回来谁后回来完全看网络,最后渲染的可能是第一次的结果。这不是玄学,是典型的竞态。解决办法很土但有效:给每次查询打一个自增序号,响应回来时比对序号,不是最新的一律丢弃。
// pages/list/list.js const api = require('../../utils/api'); let seq = 0; // 模块级序号,页面实例间共享 Page({ data: { loading: false, list: [], errMsg: '' }, async onLoad(options) { this.params = { from: options.from, to: options.to, date: options.date }; await this.fetchList(); }, async fetchList() { const { from, to, date } = this.params; if (!from || !to) { return this.setData({ errMsg: '请选择出发和到达车站' }); } if (from === to) { return this.setData({ errMsg: '出发站与到达站不能相同' }); } const current = ++seq; this.setData({ loading: true, errMsg: '' }); try { const res = await api.queryTrains({ from, to, date }); if (current !== seq) return; // 已有更新的查询,丢弃本次结果 const list = res.list.map(normalizeTrain) .sort((a, b) => a.departMinute - b.departMinute); this.setData({ list, loading: false }); } catch (e) { if (current !== seq) return; this.setData({ loading: false, errMsg: e.message || '查询失败,请重试' }); } } });逐段看逻辑。校验放在请求之前而不是之后,是为了省一次无意义的网络往返;from === to这条判断看起来多余,实际上不写就会有人查「北京到北京」然后拿到一屏错误数据。const current = ++seq是这段代码的核心,seq在模块作用域而非data里,因为它不需要驱动渲染,放data反而会多一次 setData。两个if (current !== seq) return分别挡住了成功分支和失败分支的过期响应——只挡成功分支是常见疏漏,慢请求超时后弹出一个「查询失败」,而用户其实已经看到正确结果了,这个体验非常伤。最后按departMinute排序,因为接口返回顺序不保证,早班车排在最前符合用户预期。
loading状态要配合骨架屏使用,别只放一个转圈。列表页首屏白屏超过 800ms,用户的直觉是「卡了」,点击返回的概率明显上升。骨架屏给的是「有东西正在来」的预期,成本不过十几行 WXML。
3. 站点与日期:把模糊输入变成可查询的参数
查询失败的投诉里,一半以上不是没有这趟车,而是「站名没匹配上」。用户输「北京」,系统里只有「北京南」和「北京」两个站;用户输「beijing」,字典里只有中文;用户输「bj」,什么都没发生。这一章解决的就是「怎么把人的模糊表达变成确定的站点编码」。
3.1 站点字典的三路索引与打分排序
索引要支持三种输入:中文全名、中文片段、拼音(全拼与首字母)。做法是建两张哈希表,中文一张、拼音一张,查询时三路都查一遍,合并去重后按匹配质量打分排序。打分规则不复杂但要明确:完全相等 100 分,前缀命中 80 分,包含命中 60 分,拼音命中按同等规则降一档处理。
// utils/station-index.js const stations = require('../data/stations'); const byName = Object.create(null); // 中文名 -> 站点对象 const byPinyin = Object.create(null); // 全拼/首字母 -> 站点对象数组 stations.forEach(s => { byName[s.name] = s; (byPinyin[s.pinyin] || (byPinyin[s.pinyin] = [])).push(s); (byPinyin[s.shortPinyin] || (byPinyin[s.shortPinyin] = [])).push(s); }); function score(station, kw) { const name = station.name; if (name === kw) return 100; if (name.indexOf(kw) === 0) return 80; // 前缀命中:输入「北京」命中「北京南」 if (name.indexOf(kw) > -1) return 60; // 包含命中 return 0; } function searchStations(keyword) { const kw = String(keyword || '').trim().toLowerCase(); if (!kw) return []; const hit = new Map(); Object.keys(byName).forEach(name => { const s = score(byName[name], kw); if (s > 0) hit.set(byName[name].code, { station: byName[name], score: s }); }); (byPinyin[kw] || []).forEach(s => { const old = hit.get(s.code); if (!old || old.score < 70) hit.set(s.code, { station: s, score: 70 }); }); return Array.from(hit.values()) .sort((a, b) => b.score - a.score || a.station.name.length - b.station.name.length) .slice(0, 10) .map(v => v.station); } module.exports = { searchStations, byName };关键点在score这个函数。「输入北京查不到北京南」这个坑,根因就是只用了startsWith之外的精确匹配,或者只匹配了车次数据里出现过的站名。用indexOf(kw) === 0做前缀匹配就能覆盖大部分情况,再补一个包含匹配兜住「南」这种单字输入。第二个排序键station.name.length是经验之谈:同样 80 分时,「北京」应该排在「北京南」前面,因为用户输两个字时大概率想要那个短名站。slice(0, 10)限制联想条数,超过十条在小屏上要滚很久,反而降低选择效率。
byName用Object.create(null)而不是{},是为了绕开原型链——用户输入constructor或toString时不会命中一个函数。这个细节平时看不出来,一旦有人手贱输入奇怪字符就会报错。
3.2 输入联想组件的防抖与本地优先策略
联想输入的性能问题不在于算得快不快,而在于setData调得太勤。每敲一个字符就setData一次完整候选列表,输入「北京南」三个字会触发三次渲染,在低端安卓机上能明显感觉到输入框卡顿。标准做法是防抖 200ms,并且只在候选集真正变化时才 setData。
// components/station-picker/station-picker.js const { searchStations } = require('../../utils/station-index'); function debounce(fn, wait) { let timer = null; return function (...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), wait); }; } Component({ data: { keyword: '', candidates: [], focus: false }, lifetimes: { attached() { // 防抖函数挂在实例上,避免每次输入重建 this.doSearch = debounce(this.search.bind(this), 200); } }, methods: { onInput(e) { const keyword = e.detail.value; this.setData({ keyword }); // 只更新输入框,候选列表延后 this.doSearch(keyword); }, search(keyword) { const candidates = searchStations(keyword); // 候选集没变就不 setData,省掉一次渲染层通信 if (JSON.stringify(candidates) === JSON.stringify(this.data.candidates)) return; this.setData({ candidates }); }, onPick(e) { const station = e.currentTarget.dataset.station; this.triggerEvent('change', station); this.setData({ candidates: [], keyword: station.name, focus: false }); } } });onInput里先同步更新输入框的值、再异步查候选,这个顺序不能反。反过来的话,防抖期间输入框的值是旧的,用户会看到自己打的字被「吞掉」了一瞬间。doSearch在attached里创建一次而不是写在onInput里,是因为每次输入都新建一个 debounce 闭包,定时器句柄不共享,防抖会彻底失效——这是个很隐蔽的写法错误。候选集比较用了JSON.stringify,够用且便宜,站点对象字段少,序列化开销可以忽略。
关于「本地优先」,我的建议是默认全本地。站点字典是静态数据,几百到几千条,打进包里几十 KB,比每次请求远端联想接口快一个数量级,还省流量。只有在你需要覆盖境外站点、数据每周都在变的时候,才考虑「本地命中不足两条时补一次远端请求」,并给远端结果加 24 小时缓存。
3.3 日期参数与 iOS 的解析差异
日期看着最无害,实际是翻车率最高的一个参数。new Date('2024-05-01')在部分 iOS 版本上返回Invalid Date,原因是那套 JavaScript 引擎对带连字符的日期串解析跟标准实现不一致。规避方式统一换成斜杠,或者干脆自己拆年月日构造。
// utils/date.js function parseDate(str) { // 不要直接 new Date('2024-05-01'),部分 iOS 上得到 Invalid Date if (!str) return null; const [y, m, d] = String(str).split('-').map(Number); return new Date(y, m - 1, d); // 月份从 0 开始 } function formatDate(date) { const p = n => (n < 10 ? '0' + n : String(n)); return `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())}`; } // 计算相对今天的第 n 天,用于「今天/明天/后天」快捷选择 function offsetDay(n) { const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + n); return formatDate(d); }new Date(y, m - 1, d)这种构造方式在所有端上都可靠,代价是要自己拆字符串。setHours(0,0,0,0)是为了消除时分秒——如果不归零,offsetDay(0)和offsetDay(1)之间的差值可能是 23 小时 59 分,用getTime()做缓存键会漏命中。日期参数本身的格式约定也要统一,全链路只用YYYY-MM-DD,不要在不同页面混用时间戳,混用之后排查起来非常痛苦。
| 参数 | 格式 / 取值 | 说明 |
|---|---|---|
| from / to | 站点编码字符串 | 用编码不用中文名,避免同名站与编码变更问题 |
| date | YYYY-MM-DD | 全链路统一,进请求前不再二次格式化 |
| trainTypes | G / D / C / K / T / Z 组合 | 为空表示全部车型,前端筛选用 |
| earliestDepart | 分钟数,如 360 | 配合时间筛选,比传06:00少一层解析 |
| page / pageSize | 整数,默认 1 / 20 | 分页参数,pageSize 别超过 50 |
4. 余票列表渲染:setData 的边界与长列表策略
列表页是用户停留最久的地方,也是最容易做坏的地方。余票要求「看起来是实时的」,但小程序的渲染层和逻辑层之间隔着一次通信,每次 setData 都有成本。很多项目卡顿不是因为数据量大,而是因为刷新频率高、刷新范围大——每 30 秒整表重设一次,一百条车次乘五个席别,一次就是几百个节点重排。
4.1 列表项结构与骨架屏
WXML 结构要扁平。层级一深,节点数量涨得比想象中快,而且样式继承容易出意外。一个车次项分两行:上行是时间、站名、时长,下行是席别标签横向滚动。
<!-- pages/list/list.wxml --> <view class="train-item" wx:for="{{list}}" wx:key="id"> <view class="row row--main"> <text class="time">{{item.departText}}</text> <text class="station">{{item.fromStation}}</text> <text class="duration">{{item.durationText}}</text> <text class="station">{{item.toStation}}</text> <text class="time">{{item.arriveText}}<text wx:if="{{item.plusDay}}" class="plus">+1</text></text> </view> <scroll-view class="seats" scroll-x enable-flex> <view class="seat {{seat.remain === 0 ? 'seat--empty' : ''}}" wx:for="{{item.seats}}" wx:for-item="seat" wx:key="type" >// pages/list/list.js —— 定时刷新余票,只更新变化的条目 refreshRemain() { const ids = this.data.list.map(t => t.id); api.queryRemainBatch(ids).then(res => { const patch = {}; res.forEach(change => { const i = this.data.list.findIndex(t => t.id === change.id); if (i < 0) return; // 只设置变化的那一条的 seats 字段,路径写法避免整表传输 patch[`list[${i}].seats`] = change.seats; }); if (Object.keys(patch).length) this.setData(patch); // 合并成一次调用 }).catch(() => {}); }patch这个对象里放的是数据路径字符串,list[3].seats这种写法小程序是支持的,等价于只更新这一条。注意多次 setData 一定要合并成一次:循环里调十次 setData 和合并成一次调,耗时差好几倍,因为每次调用都是一次逻辑层到渲染层的完整通信。另一个边界是单次 setData 的数据量,经验阈值控制在 256KB 以内比较稳妥,超过之后序列化耗时会明显上升,具体表现是掉帧而不是报错,所以容易被忽略。findIndex在几十条数据上没问题,一旦上百条并且每次都全量查,可以预先建一个id -> index的映射表,用空间换时间。
还有一个隐藏项:setData传的是 JSON 可序列化的数据,函数和 undefined 会被丢掉。列表对象里别塞方法,也别依赖 undefined 判断——用null或者显式布尔字段。
4.3 分页加载与长列表的渲染窗口
接口分页和渲染窗口是两件事,很多人只做了前者。接口分页解决「一次拉太多」,渲染窗口解决「一次渲染太多」。五十条以内,接口分页足够;超过一百条,就得考虑只渲染可视区附近的数据。
// pages/list/list.js —— 触底加载下一页 onReachBottom() { if (this.data.loading || !this.data.hasMore) return; // 双保险,防止重复请求 this.setData({ loading: true, page: this.data.page + 1 }); api.queryTrains({ ...this.params, page: this.data.page }) .then(res => { const more = res.list.map(normalizeTrain); this.setData({ list: this.data.list.concat(more), // concat 产生新数组,保证视图更新 hasMore: more.length >= PAGE_SIZE, loading: false }); }) .catch(() => this.setData({ loading: false })); }两个细节。onReachBottom必须做loading双保险,因为触底事件在快速滑动时会连续触发多次,不做判断就会并发三个同页请求,结果列表里出现重复车次。list: this.data.list.concat(more)而不是push,是因为直接改this.data引用视图不会更新,这是新手最常犯的错之一。
如果要上虚拟列表,核心是一段滚动区间的算术:容器高度固定、每项高度固定(或预估),根据scrollTop算出起始下标和渲染数量,再用一个撑高的占位 view 保证滚动条长度正确。
// 固定行高场景下的可视区间计算 const ITEM_HEIGHT = 120; // px,需与 WXSS 中 .train-item 高度一致 const BUFFER = 3; // 上下各多渲染 3 条,防止快速滚动露白 onScroll(e) { const start = Math.max(0, Math.floor(e.detail.scrollTop / ITEM_HEIGHT) - BUFFER); const end = start + Math.ceil(this.viewportH / ITEM_HEIGHT) + BUFFER * 2; if (start === this.data.renderStart && end === this.data.renderEnd) return; this.setData({ renderStart: start, renderEnd: end }); }ITEM_HEIGHT必须和样式里写死的高度完全一致,差 2px 累积到几十条之后滚动位置就会飘。席别数量不同的车次高度不一致时,固定行高方案直接失效,这时要么用预估高度 + 动态修正,要么老老实实做分页。我的建议是:先做分页,等真的有一百五十条以上的线路再考虑虚拟列表,过早引入只会增加调试成本。
5. 避坑与排查:五条真机上踩出来的记录
下面五条都是「开发工具里一切正常、真机上必现或偶现」的问题。开发工具的渲染层和真机不是一回事,尤其是 iOS 和高刷新率安卓屏,很多表现差异都出在这里。
5.1 数据与接口层面的三条
第一条:日期显示 NaN。现象是列表页日期过滤器上显示NaN-NaN-NaN,安卓正常,iOS 必现。 原因是new Date('2024-05-01')在部分 iOS 上返回 Invalid Date,后续getFullYear()拿到 NaN。 解决是把解析统一收口到 utils/date 的parseDate,内部用split('-')拆开后new Date(y, m-1, d)构造,禁止业务代码里出现裸的new Date(字符串)。
第二条:切换站点后列表串车次。现象是用户把出发站从 A 改成 B,列表闪一下之后显示的还是 A 到 C 的车次,再点一次才正常。 原因就是前面说的请求竞态:两个请求都在飞,先发的后回,覆盖了后发的结果。 解决是加模块级自增序号,响应回来先比序号,不一致直接 return;成功和失败两个分支都要挡。这条不是偶发,在网络差的环境下几乎必现。
第三条:余票刷新后车次信息错位。现象是手动下拉刷新后,某几趟车的余票数字对不上,重启小程序又恢复正常。 根因是wx:key用了index,列表节点被按位置复用,数据错配到别的行。 解决办法是把 key 换成业务唯一键,并在 model 层保证这个键真的唯一——trainCode单独用是不够的,同一天同车次可能因为出发站不同出现两次,得拼上 from/to/date。另外,任何「按位置更新」的 setData 路径写法都要复查一遍下标是否正确。
5.2 渲染与交互层面的两条
第四条:给输入框加防抖之后,联想候选偶尔不出现。现象是快速输入时正常,慢慢输入反而偶尔没有候选。 原因是把 debounce 包装写在了onInput里,每次输入都新建一个闭包,clearTimeout清的是新闭包的 timer,旧定时器照常执行,最后一次输入的定时器反而可能被前一次的 clearTimeout 清掉。 解决办法是在attached/onLoad里创建一次防抖函数并挂到实例上,输入事件里只调用不重建。同类问题也出现在节流和「点击后 2 秒内禁止重复提交」这类逻辑上。
第五条:触底加载不触发,或者一触发就三连。现象分两种:一是滑到底部毫无反应,二是一次触底加载了三页而且出现重复数据。 前者通常是scroll-view和页面滚动混用——用了scroll-view却没绑定bindscrolltolower,而是等页面的onReachBottom,这两者不会互相触发。后者是没有loading互斥锁。 解决是先定死一种滚动方案:要么整页滚动用onReachBottom,要么scroll-view用bindscrolltolower并给固定高度,绝不混用。互斥锁用this.data.loading加请求完成时复位,注意 catch 分支也必须复位,否则一次失败之后永远加载不了。
提示:这五条里只有第三条和第五条能在开发工具里勉强复现,其余三条必须真机验证。上线前至少要在 iOS 和一款中低端安卓上各走一遍「改站名、快速切换、断网重连、下拉刷新」。
6. 进阶:把查询做成可复用能力的三层缓存与验证清单
查询链路跑通之后,下一步不是加功能,而是加缓存和验证。缓存做对了,用户重复查询同一线路时几乎瞬时出结果;验证做对了,你才知道缓存有没有把过期的余票喂给用户。
缓存分三层,失效策略各不相同:
| 缓存对象 | 存放位置 | 失效条件 | 备注 |
|---|---|---|---|
| 站点字典 | 包内静态 JSON | 随版本更新 | 不设 TTL,减少启动期 IO |
| 车次列表(不含余票) | Storage,键含 from/to/date | 当天 24:00 或跨天 | 车次时刻表当天内基本不变 |
| 余票数据 | 内存,页面级 | 60 秒 TTL | 绝不落 Storage,避免展示陈旧余票 |
// utils/cache.js —— 带 TTL 的轻量缓存,只用于车次列表 const KEY = 'train_list_cache_v1'; const DAY_MS = 24 * 60 * 60 * 1000; function readList(from, to, date) { const all = wx.getStorageSync(KEY) || {}; const item = all[`${from}_${to}_${date}`]; if (!item) return null; if (Date.now() - item.ts > DAY_MS) return null; // 过期直接当没有 return item.list; } function writeList(from, to, date, list) { const all = wx.getStorageSync(KEY) || {}; all[`${from}_${to}_${date}`] = { list, ts: Date.now() }; // 只保留最近 20 条查询,防止 Storage 无限膨胀(上限 10MB) const keys = Object.keys(all); if (keys.length > 20) { keys.sort((a, b) => all[a].ts - all[b].ts) .slice(0, keys.length - 20) .forEach(k => delete all[k]); } wx.setStorageSync(KEY, all); }这段代码有两个容易忽略的点。一是缓存键里必须带日期,很多人只用了「出发站+到达站」,结果第二天打开还是昨天的车次,用户看到的是已经开走的车。二是清理逻辑,小程序的 Storage 单域名上限 10MB,看着很大,但车次列表带完整席别信息时一条能到 20KB 以上,不清理迟早写满,写满之后setStorageSync抛异常,而它抛异常的地方往往在异步回调里,很容易被吞掉。
验证方法我给一份自己的清单,每次改动查询逻辑都走一遍:断网状态下发起查询,看错误提示是否友好且能重试;把网络限速到 2G 模拟慢请求,连续切换三次站点,看最终结果是否属于最后一次选择;把系统时区改成 UTC+0,检查「今天/明天」的日期是否仍然正确;手动构造一趟跨天车次(出发 23:50、到达 01:20),确认+1标记和时长计算都对;最后连续触底五次,确认没有重复车次、没有一直转圈。
血泪经验是:这套东西里最贵的不是代码量,是「想当然」。我曾经因为跳过了跨天车次这一条,在凌晨时段收到一堆「时长显示 -22 小时」的反馈,排查了两个小时才发现是到达时间减出发时间成了负数。从那以后我每次改完时间相关逻辑,都会强制把跨天、跨月、跨年三个用例跑一遍再提交。希望帮到你。
本文还有配套的精品资源,点击获取