拓冰建站拓冰建站
首页 / 资讯中心 / 正文

uni-app答题小程序开发实战:从倒计时到断网续答的完整方案

简介一份基于uniapp框架编写的考试答题类小程序源码主要面向希望学习跨平台开发或考试类应用交互逻辑的初学者。项目完整覆盖单选、多选、判断三类常见题型并实现上一题/下一题自由切换题目顺序与索引管理清晰所有题目与答案均在前端静态预设虽未接入真实后台却因此降低了理解门槛便于集中研读页面布局、数据绑定、事件响应等核心逻辑。压缩包为zip格式约1.47MB内含可直接导入HBuilderX的工程文件适合课程设计、毕业设计参考或uniapp入门练手。已有2371人学习下载结合源码可快速掌握跨端打包思路若希望发展为实际在线考试系统还能在此基础上扩展用户登录、后台题库维护、自动判分、成绩排行榜与防作弊机制具备良好的延伸价值。 接手这类项目之前我一直觉得答题小程序没什么技术含量无非是渲染题目、收集答案、交卷算分。直到真的给一家驾考培训机构做科目一练习与考试系统的时候才发现把一张卷子放到微信小程序和App里跑通、跑稳需要处理的问题比一个后台管理系统复杂得多。尤其是倒计时被系统切后台、用户答题到一半误退、提交时断网这些“日常小事”每一个都能让一次考试变成用户投诉的起点。这篇文章打算把我在 uni-app 里做考试答题类小程序的完整思路和落地细节拆开讲清楚覆盖从题目数据模型、答题状态机、本地缓存和断点续答到支付、分享跳转、上架审核和疑难杂症排查的全过程。适合正在用 uni-app 做答题、问卷、测评类应用或者准备把小程序上架安卓应用市场的开发者参考。内容会偏实操不会只讲概念。1. 项目开始前我先想清楚了三件事1.1 为什么这类项目选 uni-app 是合理的考试答题类产品有一个共同特点运营端希望覆盖尽量多的平台。同一套题库今天在微信小程序上线明天可能就要打包成安卓App后天客户又想在手机浏览器里打开H5版本。如果每个端都单独写一套光是题目渲染和交卷逻辑就要重复维护三遍题库一改三个端跟着返工。uni-app 在这里的价值不是“一套代码跑三端”这种宣传话术而是把页面、组件、路由、状态管理这些前端公共部分抽了出来三端共享。真正有平台差异的只有调用原生能力的那一层比如扫码、支付、定位这些 API 在 uni-app 里也做了统一封装大部分场景不用自己写条件编译。对于做过 Vue 的团队上手成本确实很低招人也好招。当然不是所有项目都适合 uni-app。如果产品核心是复杂的原生交互比如实时音视频课堂、AR 识题那还是老老实实分开做。答题类应用没有特别重的原生依赖属于 uni-app 最舒服的射程范围。1.2 题目数据结构的“一次设计三端复用”答题类项目的核心资产是题库不是页面。所以我把大量时间花在了题目数据模型的设计上而不是先画界面。我最终用的是这样的结构{ questionId: Q10086, type: single, // single | multiple | judge | fill categoryId: CAT_001, stem: 驾驶机动车在道路上违反道路交通安全法的行为应受到什么处罚, options: [ { label: A, content: 记分 }, { label: B, content: 罚款 }, { label: C, content: 警告 }, { label: D, content: 以上都是 } ], answer: D, score: 1, explain: 依据道路交通安全法相关规定上述行为均需承担相应责任。 }这个结构看着简单但有两个细节我吃过亏。第一answer 字段必须用纯字符串或纯数组不要和选项渲染结构耦合。比如选项里可能有图片、可能有音频如果把答案写成“A、B”这种带展示格式的字符串后面出题时选项顺序打乱答案就全错了。我踩过这个坑所以现在答案永远存 option 的 label而不是 content。第二explain 解析字段一定要预留。答题类产品做完考试功能后最常见的迭代就是“错题解析”“收藏夹”“每日一练”这些功能全都依赖题目自带解析而不是交卷后从后台现查。题库接口把 explain 带上前端本地就能做错题本不需要为每个新课再加一个接口。1.3 答题记录的存储结构答题过程中用户的每一步操作都要能落到本地所以我单独设计了一套答题记录结构{ examId: EXAM_20250101_001, questionId: Q10086, userAnswer: [D], correct: true, score: 1, spentSeconds: 12, updatedAt: Date.now() }这套记录有四个用途答题卡状态展示、交卷判分、错题回顾、学习报告统计。注意 userAnswer 我用的是数组即使是单选题也存数组这样题型从单选扩到多选时前端逻辑不用大改后端判分也更统一。2. 答题核心机制从渲染题面到倒计时交卷2.1 状态机设计为什么答题页不能用一堆 if/else 控制状态答题页是整个项目里最容易写烂的页面。很多人习惯用一堆布尔变量控制显示逻辑比如isRunning、isPaused、isSubmitting、isFinished最后组合出几十种状态改一个变量牵一发动全身。我后来重构成了标准状态机const ExamState { PENDING: pending, // 待开始展示考试说明 RUNNING: running, // 答题中 PAUSED: paused, // 暂存/被系统切后台 SUBMITTING: submitting, // 交卷请求中 FINISHED: finished // 已交卷 };页面里所有操作都只做一件事触发状态迁移。比如倒计时归零触发RUNNING - SUBMITTING用户点击交卷触发RUNNING - SUBMITTING提交成功后进入FINISHED。每个状态对应的 UI 是固定的状态变了 UI 自动跟着变。这样做最大的好处是永远不会出现“页面显示在答题中但倒计时已经停了”这种状态错乱的 bug。测试同学报问题的时候我只需要问一句“当前处于哪个状态”就能定位到对应的状态迁移逻辑而不是翻遍十几个 if 分支。2.2 倒计时的“假死”问题与正确清理方式考试类应用最怕倒计时不准。用户考到一半切出去回了个微信回来发现倒计时还在走但实际时间早过了这种问题不止一次被客户投诉。原因是小程序和 App 在进入后台后定时器会被系统挂起不会继续触发。正确的做法不是依赖定时器累加而是用时间戳做差值计算。我的实现思路是这样的data() { return { remainSeconds: 3600, lastActiveAt: Date.now() }; }, startTimer() { this.timer setInterval(() { const now Date.now(); this.remainSeconds Math.max(0, this.remainSeconds - Math.floor((now - this.lastActiveAt) / 1000)); this.lastActiveAt now; if (this.remainSeconds 0) { this.triggerSubmit(); } }, 1000); }, onHide() { // 页面隐藏时记录当前剩余时间不清理定时器也行但最好清掉 this.remainSeconds this.remainSeconds - Math.floor((Date.now() - this.lastActiveAt) / 1000); clearInterval(this.timer); }, onShow() { this.lastActiveAt Date.now(); this.startTimer(); }核心思路是无论 setInterval 有没有被挂起每次执行都用当前时间戳减去上次活跃时间戳算出真实流逝的秒数。这样即使切后台五分钟再回来恢复后第一秒就把这五分钟扣掉了用户看到的倒计时是准的。这里还要注意一个细节设置时间为 0 后一定要立刻触发交卷并且把交卷按钮置灰、加二次确认弹窗。因为在严格模式下时间到就等于考试结束不能给用户任何继续答题的入口否则成绩有效性会被质疑。2.3 答题卡与滚动定位性能优化和交互细节答题卡是用户跳题的主要入口。一个一百题的试卷点开答题卡点第 80 题页面要能滚到第 80 题的位置。我用的是scroll-into-view方案每个题目容器设置唯一的 idscroll-view scroll-y :scroll-into-viewcurrentQuestionId scroll-with-animation view v-for(item, index) in questionList :idq_ index :keyitem.questionId !-- 题目内容 -- /view /scroll-view这里有一个性能相关的经验一次渲染 100 道题在低端安卓机上会有明显卡顿尤其是题目里带图片的时候。我的做法是在答题页只渲染当前题和前后两题其他题目用占位 view 撑高度等用户跳到附近时再渲染。这个“虚拟列表”的简化版实现成本不高但对滑动流畅度提升很明显。答题卡的选中状态直接读本地答题记录用户答过的题在答题卡上标记为绿色未答的标记为灰色。这个数据不需要从服务端实时拉取本地记录就够用。3. 真实考试场景下数据保存与异常恢复比想象中重要3.1 用户误退、小程序被杀怎么恢复答题记录答题类产品有一个非常真实的场景用户答了 70 题突然被一个电话打断小程序在后台被系统回收了。等他重新打开如果发现答案全没了他是不会觉得“这是系统自动回收”的只会觉得“你们产品是垃圾”。所以我在设计上强制要求每切换一道题就把当前答案和剩余时间写入本地缓存。saveDraft() { const draft { examId: this.examId, startedAt: this.startedAt, remainSeconds: this.remainSeconds, answers: this.answersMap, lastQuestionIndex: this.currentIndex }; uni.setStorageSync(exam_draft_${this.examId}, draft); }启动时在首页或考试入口页检查本地缓存如果存在未完成的考试记录给出“是否继续上次答题”的弹窗。用户点击继续直接恢复到上次的题目位置和剩余时间。这个功能的实现成本极低但对口碑的提升是决定性的。很多考试类产品死就死在用户答了一半被中断重新进来又要从头答一次两次用户就流失了。3.2 断网弱网下如何保证交卷不丢答题过程中断网不可怕可怕的是用户点交卷时刚好断网请求发不出去前端直接报错用户以为交卷成功了关掉页面走了后台却没收到卷子。我的方案是一个简易的离线提交队列function enqueueSubmit(payload) { const queue uni.getStorageSync(submitQueue) || []; queue.push({ payload, retryCount: 0 }); uni.setStorageSync(submitQueue, queue); flushSubmitQueue(); } function flushSubmitQueue() { const queue uni.getStorageSync(submitQueue) || []; if (!queue.length) return; uni.getNetworkType({ success(res) { if (res.networkType none) return; // 无网络等待下一次触发 const item queue[0]; requestSubmit(item.payload) .then(() { queue.shift(); uni.setStorageSync(submitQueue, queue); flushSubmitQueue(); }) .catch(() { // 失败保留在队列下次启动或联网时重试 }); } }); }这个队列的触发时机包括交卷时、App 从后台回前台时、网络状态从无网变为有网时。通过uni.onNetworkStatusChange监听网络恢复只要网络一回来队列里的交卷请求就会自动补发。同时交卷按钮的交互要改成点击后先本地保存再发起请求请求中显示“正在交卷”请求成功才提示“交卷成功”。如果请求失败提示“网络异常请检查网络后重试”但答案已经在本地用户不需要重新作答。3.3 题库放远端还是本地sqlite 的意义早期我把题库全部放在服务端用户每次考试都实时拉取。后来客户接了个专门的考点培训项目题库有 8000 多道题每道题还带图片一次拉取光传输就得几秒弱网环境直接卡死。后来我做了本地题库的方案。小程序端用uni.setStorageSync存数据有大小限制不适合存大题库所以我用了 sqlite。uni-app 官方有plus.sqlite的封装在 App 端可以使用配合 HTML5 的能力做本地数据库存储。具体做法App 启动时检测本地题库版本号和服务端版本号不一致就下载最新的题库 JSON解压后写入 sqlite然后所有刷题、搜索、随机组卷都走本地查询。对于 H5 端因为浏览器没有 sqlite我退而求其次用 IndexedDB 做了一层封装。题库本地化之后随机组卷的速度是毫秒级的用户即使在地铁里没信号也能刷题。这个体验线上题库是给不了的。4. 小程序支付、分享与上架合规的隐形门槛4.1 支付功能报名、练习会员该怎么接答题类产品最常见的收费场景是两类一类是考试报名费另一类是 VIP 题库练习权限。这两类在小程序里走的支付方式有区别。微信小程序内支付用uni.requestPayment调起微信支付需要后端先统一下单拿到支付参数uni.requestPayment({ provider: wxpay, timeStamp: res.data.timeStamp, nonceStr: res.data.nonceStr, package: res.data.package, signType: RSA, paySign: res.data.paySign, success() { // 支付成功跳转开通成功页 }, fail(err) { // 用户取消或支付失败 } });这里最常见的翻车点是签名。支付签名一定、必须、绝对要在服务端生成前端只负责把后端返回的参数透传给requestPayment。有人图省事想在前端直接拼参数签名小程序一旦被反编译密钥直接暴露轻则支付功能被封重则资金安全出问题。另外如果做的是 iOS 端 App虚拟支付类目很敏感充值类、会员类的虚拟商品不能直接用微信支付或支付宝苹果规定必须走 IAP 内购。很多团队第一次上架 iOS 时在这个环节被拒不是代码问题是业务合规问题。我的经验是如果是卖实体考试资料走微信支付没问题如果是卖线上题库会员iOS 端要提前规划 IAP 对接或者先按运营要求只上安卓和微信小程序避开这个坑。4.2 自定义分享被全局覆盖的问题答题做完后很多产品会加一个“邀请好友 PK”的分享功能。分享有两个入口一个是右上角菜单一个是页面里的自定义按钮。一开始我在onShareAppMessage里做了全局配置然后在某个页面想单独改分享参数发现怎么改都不生效页面一直走的是全局配置。排查下来才发现问题出在uni.showShareMenu和页面级onShareAppMessage的覆盖关系上。在页面里单独写onShareAppMessage时要特别注意返回值必须是一个对象而且包含 title、path、imageUrl 三个字段缺一个就会回退到全局配置。我在某个页面只返回了 title 和 imageUrl忘记写 path结果分享出去的卡片打开是默认首页用户一脸懵。还有一个高频问题分享链接如何带参数。比如从分享卡片点进来的用户需要自动标记为邀请人的下线要在 launch 的 options 里取参数onLoad(options) { if (options.inviterId) { // 记录邀请人ID上报后台 } }但注意微信小程序从二维码、分享卡片、公众号菜单进入参数来源的字段可能不一样。分享卡片走onLoad optionsApp 被拉起时走onShow里的plus.runtime.arguments这个差异很容易被忽略。我的做法是封装一个getLaunchParams()函数统一从各端原生入口读取参数页面里只调这一个函数避免三端各写一套取参逻辑。4.3 manifest、隐私协议与安卓应用市场上架小程序的manifest.json是个容易忽视但非常关键的文件。微信小程序的 appid、App 的 android 包名、iOS 的 Bundle Identifier全都要在这里配好。我见过有人把 appid 配错了结果微信开发者工具一直提示“不是开发者”排查半天才发现是小程序 appid 填成了别人的。考试类 App 上架安卓应用市场最容易卡在隐私协议上。用户首次启动 App 时必须弹窗展示隐私政策用户同意后才能开始收集任何个人信息。这里有一个热词提到的问题如果用户不同意隐私政策App 必须退出。这个问题在小程序端没有提供“退出小程序”的 API也没法直接关闭小程序我在小程序端是放了一个“不同意则无法继续使用”的兜底提示页用户点击同意才能进入。如果是 App 端处理方式就简单直接// 用户拒绝隐私协议 if (!agreed) { plus.runtime.quit(); // 安卓和iOS App端可以直接退出应用 }安卓市场审核另一个常见问题是权限声明。考试类 App 如果用到了摄像头扫码、麦克风录音、定位必须在 manifest 的权限配置里说明用途并在运行时向用户申请。不要贪多把用不到的权限全部勾掉很多应用市场会扫描 APK 里的权限声明发现权限和类目不匹配就直接拒审。5. 一次发版后我处理过的几个“小而难”问题5.1 软键盘把答题输入框顶上去答题类应用里有主观题就需要输入框而输入框和软键盘的组合在各端的表现堪称群魔乱舞。微信小程序里input 组件有adjust-position属性默认 true键盘弹起时页面会自动上推。但我遇到过一个情况页面底部有固定按钮比如“下一题”在 iOS 上adjust-position会失效按钮被软键盘盖住。后来我改成监听键盘高度手动做位移uni.onKeyboardHeightChange(res { if (res.height 0) { this.keyboardHeight res.height; } else { this.keyboardHeight 0; } });然后给底部按钮的容器动态绑定margin-bottom: ${keyboardHeight}px。这个方法在微信小程序和 App 端都验证过稳定生效。App 端还要注意 iOS 的adjust-position和 input 组件的cursor-spacing配合。给 input 设置cursor-spacing“20”会让光标距输入框底部保留 20px 的空间键盘弹出时输入框不会被完全遮住。安卓端如果还出现遮挡检查一下 App 的软键盘模式配置plus.android.importClass设置adjustResize模式而不是默认的adjustPan。5.2 扫码扫出来是一串数字有个考务功能需要用户扫准考证上的二维码进入考场。结果有用户反馈扫码之后页面没反应只有一串数字。排查过程很有意思。uni.scanCode默认会同时支持二维码和条码准考证上的二维码如果排版比较密集或者用户扫的时候角度不对系统可能识别成了条形码返回的结果就是一串纯数字编号。解决办法是加scanType限制uni.scanCode({ scanType: [qrCode], // 只扫二维码不识别条形码 success(res) { const result res.result; if (/^\d$/.test(result)) { // 纯数字可能是条码误识别提示用户重新扫码 uni.showToast({ title: 识别失败请对准二维码重新扫描, icon: none }); return; } // 正常处理 } });这个改动看起来很小但对考场这类场景很重要——考试前时间紧张用户扫不出来会非常焦虑提前把误识别拦截掉能少很多现场咨询。5.3 下拉刷新与页面滚动的冲突考试列表页我一开始开启了页面级enablePullDownRefresh用于手动刷新考试场次。结果用户反馈说在列表里往下划的时候很容易误触刷新而且刷新动画会打断浏览。排查后发现问题不是enablePullDownRefresh本身而是页面里还有一块滚动区域。当手指在滚动区域顶部继续下拉时会被识别成页面级的下拉刷新手势。我的处理方案是考试列表页放弃页面级下拉刷新改成一个显眼的“刷新”按钮列表数据用onShow自动刷新。这样虽然少了一个手势但避免了误触用户也不会在翻列表的时候突然看到刷新动画。与其让用户频繁误触不如把刷新入口做得明确一点考试列表这种低频刷新页面不适合用高频手势。5.4 视频题限制单视频播放滑出可视区自动暂停现在的理论考试和实操测评经常会加入视频题比如“观看以下事故视频判断责任方”。视频题的体验核心是页面滑动时如果视频滑出可视区必须自动暂停否则声音会干扰其他题目而且多个视频同时播放会卡顿。我的实现用的是IntersectionObserver监听视频元素是否还在可视区域内// 页面内创建观察器 this.videoObserver uni.createIntersectionObserver(this); this.videoObserver.relativeToViewport().observe(#video_${currentVideoId}, (res) { if (res.intersectionRatio 0) { // 滑出可视区暂停播放 const videoContext uni.createVideoContext(currentVideoId, this); videoContext.pause(); } });同时用状态变量控制“当前播放的视频 ID”每次只有这个视频能播放其他视频的播放按钮都显示为暂停态。这个逻辑在视频列表、视频题、图文混排题里都通用。还有一个细节uni.createVideoContext在小程序里必须在页面onReady之后才能创建否则取不到上下文。我在onReady里统一初始化视频上下文而不是在onLoad里做这个顺序别搞反。做答题类小程序这一年多我最大的感触是这类项目表面上是页面和接口的堆叠实际上真正决定用户口碑的全是那些“用户操作到一半发生意外”的场景。倒计时是否准确、草稿是否保存、断网是否能续传、视频是否自动暂停这些才是用户能感知到的质量。以上这些问题我也不是一次就全部规避掉的大部分都是上线后被真实用户逼着迭代出来的。如果你正在做同类项目可以按我上面这套顺序提前检查一遍应该能少熬几个排查问题的晚上。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门