小程序图鉴类应用开发:从数据索引到列表渲染的完整实践
简介宝可梦图鉴游戏小程序源码包定位为面向安卓开发初学者和宝可梦爱好者的开源示例项目解决快速查阅宝可梦名称、属性、种族值、技能、进化链等详细数据的需求也可作为课程设计与毕业设计的参考蓝本。压缩包共981个文件整体体积仅2.44MB其中920张PNG图片构成视觉素材主体23个JS脚本承载查询逻辑12个JSON文件保存宝可梦条目并配合WXML/WXSS页面描述文件呈现交互界面资源、逻辑、数据、界面层次分明。已有333人浏览学习。通过研读源码可学习SQLite或网络API的数据获取、RecyclerView列表展示、搜索过滤、动画交互等安卓常用实现项目按安卓工程规范组织包含Manifest配置、Gradle脚本、src源码、res资源等模块还附带README说明便于快速上手与排错。开发者可替换或扩充JSON数据打造自定义宝可梦图鉴适合练手、课程设计或二次开发。1. 做宝可梦图鉴先从数据模型开始做图鉴小程序最常见的尴尬是数据全拉下来了列表却卡成 PPT。console 没红字滚动一顿一顿拆开看基本都栽在同一处setData 把上千条完整对象一次性丢给渲染层白屏不是网速问题是数据通道被撑爆了。“宝可梦图鉴游戏小程序”这个标题图鉴是产品形态“游戏”对应捕捉、收集和图鉴完成度这类交互。要做顺得先把数据模型定清编号怎么索引属性怎么筛选捕捉进度存哪里。这几件事想明白页面才不至于互相拖累。下文按数据建模、列表渲染、捕捉收集、启动适配四条线把可复现的路径讲完代码和参数可直接抄进项目里改。适合写过基础小程序页面、想往数据密集型场景走一档的开发者。2. 宝可梦图鉴的数据结构与索引设计这里的复杂度不在于单条记录字段多而在于查询路径多按编号、按名字、按属性、按进化链每一项都是一个检索入口。如果每个入口都循环一遍数组页面一多必然卡。先把索引建起来让查询变成 Map 查找是整个项目最划算的一笔投资。2.1 图鉴数据的 JSON 组织与字段取舍先定一份最小可用的图鉴记录结构[ { id: 1, name: 妙蛙种子, en: Bulbasaur, types: [草, 毒], base: { hp: 45, atk: 49, def: 49, spa: 65, spd: 65, spe: 45 }, captureRate: 45, growthRate: medium-slow, evolvesFrom: null, entry: 出生时就带着一颗种子…… } ]id 用全国图鉴编号从 1 开始跨世代不重复后续做捕捉位图正好可以拿它当下标。types 用数组因为不少宝可梦是双属性后面做属性倒排索引时直接遍历拼数组就行。captureRate 是捕捉概率计算的关键参数growthRate 用于展示成长曲线evolvesFrom 记录进化前编号详情页展开进化链时不用二次查名字。字段取舍上entry 这种两三百字的长文本不要跟列表数据放同一个对象里。列表页只需要 id、name、types 三个字段参与渲染entry 放进独立 map 按需读取。这样做不是因为存储贵而是因为 setData 的 diff 和序列化成本会随字段长度增长长文本进渲染层会拖低滚动帧率。2.2 用 Map 建编号与属性倒排索引原生小程序对 ES6 Map、Set 的支持没问题可以在模块初始化时把索引建好页面 onLoad 里直接 require 这个模块拿索引。const pokemonList require(../../data/pokedex.json); const byId new Map(); const byName new Map(); const byType new Map(); for (const pkm of pokemonList) { byId.set(pkm.id, pkm); byName.set(pkm.name, pkm); for (const t of pkm.types) { if (!byType.has(t)) byType.set(t, []); byType.get(t).push(pkm.id); } } function search({ keyword , types [] }) { let ids types.length ? types .map((t) byType.get(t) || []) .reduce((acc, arr) intersect(acc, arr)) : [...byId.keys()]; if (keyword) { ids ids.filter((id) { const p byId.get(id); return p.name.includes(keyword) || p.en.toLowerCase().includes(keyword.toLowerCase()) || String(id) keyword; }); } return ids.map((id) byId.get(id)); } function intersect(a, b) { const setB new Set(b); return a.filter((x) setB.has(x)); } module.exports { byId, byName, byType, search };逻辑说明由类型到 id 的索引用数组做倒排属性筛选时对每个选中属性取 id 列表再逐个做交集。这样是并行取集合而不是遍历全量数据判断 in在数据量过万之后差距很明显。keyword 部分无所谓索引名称检索本质是子串匹配走过滤而不是精确查找。参数说明search 的 types 是“且”语义选草加毒会返回同时含这两个属性的记录如果产品设计要“或”把 reduce 里的 intersect 换成数组去重合并即可。返回的 ids 数组可以直接传给列表页做分页源避免列表页再维护一份业务过滤逻辑。2.3 本地数据与远程更新的边界pokedex.json 放代码包里用 require 同步读取启动没有异步白屏是图鉴类小程序最低成本的方案。但它占用代码包体积主包 2M 限制下不建议把全部世代的 JSON 都塞进主包。常见做法是数据文件放分包或用静态托管存数据文件启动时拉版本号判断要不要增量更新。方案加载速度包体占用更新方式适用阶段代码包 require最快占主包或分包发版更新单世代本地图鉴分包 require快占分包空间发版更新多世代数据较大时静态托管拉 JSON看网络不占包热更新运营期频繁改数据云数据库慢不占包热更新带账号体系的线上版2.4 版本号字段与启动校验无论走哪种远程方案都建议在 data 目录放一个 version.json{version: 1}。启动时拿本地 storage 里的版本号和它比对一致就跳过请求不一致再拉最新 JSON。这样不会每次冷启动都等网络返回列表页能先拿本地数据画出首屏远程数据到了之后再决定是否替换。这里版本号比较的对象是远程的版本标识而不是 JSON 本身避免把大文件拉下来之后才发现没变化。对宝可梦图鉴这种低频更新但数据量大的场景这一层校验能省下大部分启动流量。3. 小程序列表页scroll-view 分批渲染、防抖搜索与多条件筛选3.1 scroll-view 分批加载避免一次 setData 拉垮渲染页面结构上用 scroll-view 包列表bindscrolltolower 触发加载。核心代码在 appendChunk每次只往渲染层追加一页数据const PAGE_SIZE 20; let allIds []; Page({ data: { list: [], loadedCount: 0, hasMore: true }, onLoad() { const index require(../../utils/index.js); allIds [...index.byId.keys()]; this.appendChunk(); }, appendChunk() { if (this.loadingMore) return; if (!this.data.hasMore) return; this.loadingMore true; const start this.data.loadedCount; const chunk allIds.slice(start, start PAGE_SIZE); const patch { loadedCount: start chunk.length, hasMore: start chunk.length allIds.length }; chunk.forEach((id, i) { patch[list[${start i}]] index.byId.get(id); }); this.setData(patch, () { this.loadingMore false; }); } });逻辑说明这里刻意没有用this.data.list.concat(chunk)因为 concat 会生成一个全新数组setData 拿到新数组后会整体 diff前面已经渲染过的内容也要重新比对一遍。用下标 key 做增量写入渲染层只收到新增的 20 条变更diff 范围小很多。PAGE_SIZE 取 20 是经验值。小屏手机一屏大约显示 6 到 8 个图鉴条目20 条刚好覆盖首屏加一个滑动余量不会在滑动时频繁触发加载也不会让首次进入页面等待太久。3.2 300ms 防抖 多属性筛选的组合查询搜索框输入时不可能每敲一个字符都查一次尤其是拼音输入法联想阶段输入框的 value 变化非常频繁。用 300ms 防抖把查询压缩到停顿后执行let debounceTimer null; onSearchInput(e) { clearTimeout(debounceTimer); const keyword e.detail.value.trim(); debounceTimer setTimeout(() { const result search({ keyword, types: this.data.selectedTypes }); const slice result.slice(0, PAGE_SIZE); this.setData({ list: slice, loadedCount: slice.length, hasMore: result.length PAGE_SIZE }); }, 300); }search 函数就是 2.2 里那个索引查询它返回排序后的完整结果集列表页只关心前 20 条通过 hasMore 决定后续是否继续 appendChunk。这里 setData 用的是整数组替换因为搜索本身就是一次上下文切换用户期待的是新结果集增量写入反而会在瞬间显示上一批的残留。参数说明搜索状态下的 PAGE_SIZE 可以比浏览态大一些比如 30。搜索场景用户会快速按返回键清理筛选结果集往往小于 30一次给完更干脆。防抖时间 300ms 对中文输入够用如果只按英文名检索可缩到 200ms具体以连续输入不触发中间态为准。3.3 过滤结果为空、上拉加载中的 UI 状态处理空结果要单独给视图不能只显示一个空白列表。我一般会在 wxml 里用wx:if{{list.length 0 loaded true}}渲染空状态组件文案提示“没有找到该属性组合下的宝可梦”旁边放一个“清除筛选”按钮直接重置 selectedTypes 和 keyword。加载更多的锁要放在数据层而不是按钮层。bindscrolltolower 在 iOS 上触发很频繁如果只在 setData 里维护 loading 字段有可能在 setData 回调之前又进来一次。上面代码里的this.loadingMore是实例属性不走渲染层锁的粒度更细且不会带来额外 setData 开销。提示空状态组件建议用 CSS 动画做淡入不要用 display:none 切换。iOS 上 display 切换后的首帧渲染偶发白屏淡入可以把这个窗口遮过去。3.4 setData 体积与渲染开销的关系setData 的开销跟传输数据量和 diff 范围强相关这里用一个直观对照说明更新方式每次 setData 增量对渲染线程的压力全量 list 替换整个数组序列化随数量膨胀数量过千明显掉帧concat 500 条新数组全量 diff中度卡顿下标增量 20 条只序列化 20 条变更基本无感如果数据规模继续往上走比如包含全形态、Mega、地区形态的完整图鉴超过两万条普通 scroll-view 也会兜不住这时候要考虑回收列表方案通过组件复用来限制实际渲染节点数代价是列表项高度必须可控或者通过 onScroll 动态计算实现成本高不少。4. 捕捉与图鉴收集位图存储、捕捉率计算与反连点4.1 用位图字符串记录已捕捉编号图鉴进度是“游戏小程序”区别于普通展示页的核心功能。已捕捉状态如果存成数组[1, 2, 3]条目一多每次读取都要做 includes 判断写入还要去重。换成位图字符串更省事第 i 位是 0 或 1代表编号 i1 是否已捕捉。const STORAGE_KEY caught_map_v1; function readCaughtMap() { return wx.getStorageSync(STORAGE_KEY) || ; } function markCaught(id) { const current readCaughtMap(); const idx id - 1; let arr current.split(); arr[idx] 1; wx.setStorageSync(STORAGE_KEY, arr.join()); } function isCaught(id) { return readCaughtMap()[id - 1] 1; }逻辑说明全国图鉴编号从 1 开始连续递增编号直接映射字符串下标省掉一层 HashMap。5000 条记录对应 5000 字节远低于小程序本地存储的单键上限。读取时不涉及序列化用下标取字符比数组 includes 快得多。参数说明这里位图的一位对应一个编号如果游戏里要做形态区分比如同一编号的不同形态独立计数就把位图键改成 shapeId 而不是 id字符串长度不变只是业务层映射关系多一层。storage key 带 v1 是为了将来做进度迁移数据格式有破坏性变化时直接换 key 而不必兼容旧数据。4.2 捕捉率公式与精灵球权重参数捕捉判定可以近似官方公式const BALL { normal: 1, super: 1.5, ultra: 2, master: 255 }; const STATUS { normal: 1, sleep: 2, paralysis: 1.5 }; function tryCatch(pkm, ball, status) { const rate Math.max(1, Math.min(255, pkm.captureRate * BALL[ball] * STATUS[status])); const chance Math.pow(rate / 255, 0.75); return Math.random() chance; }参数倍率适用场景普通球1.0初期收集常见宝可梦超级球1.5中段图鉴有资源储备后高级球2.0后期稀有怪节省操作时间大师球255保底道具活动或任务的专属奖励睡眠状态2.0需要配合睡眠招式先手麻痹状态1.5比睡眠好挂但加成略低公式里 0.75 次方的作用是压缩低概率区间的差距captureRate 为 255 时概率是 1为 45 时约 8%为 3 时约 1%。这样御三家不会太难抓也不会让传说级宝可梦变成纯粹看运气。注意这里 Math.random 只在客户端判定只适合做演示和单机体验。正式线上版本要把随机数生成放到云函数客户端把行为上报服务端返回结果避免改 storage 就能解锁全图鉴。4.3 捕捉动画期间的按钮锁定捕捉按钮最常见的 bug 是连续点击导致重复判定一只宝可梦抓出两只。处理方式是动画期间把按钮状态置为 busyonCatchTap() { if (this.data.catching) return; this.setData({ catching: true }); this.setAnimationStep(throwing); setTimeout(() { const ok tryCatch(this.data.currentPkm, this.data.ballType, this.data.status); if (ok) markCaught(this.data.currentPkm.id); this.setData({ catching: false }); this.setAnimationStep(ok ? caught : escape); }, 1500); }状态锁加在 setData 之前而不是在 setTimeout 回调里能挡住同一次交互中连续触发的两次点击。动画时长 1500ms 要和精灵球晃动动画一致动画没到绑定时间就解锁会让玩家觉得“球还在晃结果已经显示了”。长时间运行后建议把捕捉流程抽成状态机idle、throwing、caught、escape 四态throwing 期间任何操作都返回避免 setData 和动画控制器里出现竞态。5. 启动加载页、动态设置标题与导航栏高度适配5.1 修改刚进入的加载页面首次进入加载页最怕的是白屏等数据。常见做法是把首页配成轻量欢迎页只渲染品牌和加载动画核心索引数据在 onLoad 里直接 require 本地 JSON不进网络。app.json 里首页路径指向 loadingonReady 后 wx.redirectTo 到列表页。远程更新只是启动时的一个附加动作不要阻塞首屏。数据拉完通过全局事件通知列表页刷新拉失败就继续用本地缓存保证用户先进得来。5.2 用 wx.setNavigationBarTitle 动态设置标题列表页显示“宝可梦图鉴”详情页显示宝可梦名字捕捉中显示捕捉进度这些都要靠 wx.setNavigationBarTitlewx.setNavigationBarTitle({ title: 宝可梦图鉴 · ${caughtCount}/${totalCount} });在 uni-app 工程里对应的是 uni.setNavigationBarTitleHBuilderX 下直接写 uni 前缀代码结构一样。标题长度要注意iOS 会截断超长文本进度数字写在前面比写在后面稳。5.3 胶囊按钮与顶部导航栏高度计算自定义导航栏在 iPhone 灵动岛上最容易顶到胶囊。动态计算高度的方法是拿胶囊按钮的位置反推const win wx.getWindowInfo(); const menu wx.getMenuButtonBoundingClientRect(); const navBarHeight (menu.top - win.statusBarHeight) * 2 menu.height;这段计算要在启动加载页 onReady 后执行结果存到 globalData列表页、详情页直接取用。menu.height 与状态栏高度共同决定上下留白胶囊顶部到状态栏底部的差值乘 2 加胶囊高度正好覆盖标题栏完整区域。上面拿到 navBarHeight 后再定义一个通用的 paddingTop 样式变量所有自定义导航栏的页面统一引用比每个页面各写一份安全区适配要省心得多。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →