uniapp答题系统源码解析:从JSON数据模型到多端渲染
2026/9/15 15:49:08 网站建设 项目流程

简介:一套基于uniapp开发的答题/答卷系统项目源码,适合需要快速搭建在线考试、问卷调查或知识竞赛类应用的开发者,也便于熟悉HBuilderX的学员体验一次编码多端发布(APP与小程序)的流程。项目用vue页面配合JS逻辑与JSON配置,整体结构简洁,包含底部导航、列表、轮播等常见交互模块,可作跨端开发参考。

压缩包内含23个文件,以11个vue页面组件为主,另有js逻辑与配置文件、JSON页面配置、SCSS样式表及多张PNG/JPG界面素材,覆盖从页面渲染到样式适配的主要环节;整个压缩包仅159KB,体量很轻,导入HBuilderX即可查看运行效果。目前已有984人浏览学习,适合用于课程设计或项目起步。

通过该源码可掌握答题流程设计、基础组件拆分、uni.scss主题定制以及APP/小程序编译中的常见要点,目录中分组列表、用户卡片、宫格菜单等模块也能帮助初学者快速理解uniapp的组织方式。

1. 答题-答卷系统不直接写原生小程序,而是选 uniapp 项目源码来改的核心理由

做过微信小程序原生开发的人,第一次接触答题或问卷类需求时,十有八九会陷入同一个困境:页面结构极其相似,题目类型却千变万化,一套表单逻辑要在答题、考试、问卷、报名表之间反复横跳。原生小程序里每个页面都要写独立的 wxml、wxss、js、json,四个文件绑定在一起,当题目组件从单选变到多选、评分、填空、图片上传时,改动成本几乎翻倍。更重要的是,这类项目通常不是只发微信端——公众号 H5、App、支付宝小程序都可能要,而原生小程序没有办法一套代码同时覆盖这些平台。

uniapp 解决的正是这个核心矛盾:用 Vue 语法写一次,编译到微信/支付宝/H5/App 多端。答题-答卷系统在 uniapp 里天然适合做成"数据驱动渲染"的结构——题目是 JSON,页面是渲染器,状态交给全局 store,交卷后把答题记录序列化提交给后端。而当我们拿到一份现成的"答题-答卷系统-小程序-uniapp-项目源码"时,真正要做的不是去读每一行页面代码,而是先弄懂这四件事:项目怎么组织、卷子数据长什么样、答题和答卷两种模式如何复用同一套渲染器、以及最终怎么过 manifest 配置和各平台审核。这才是这套源码里真正值钱的部分。

2. 搭建答题-答卷系统的目录骨架,数据模型和状态管理的分工是第一步

2.1 页面、组件、请求、工具四层拆分,而不是按功能模块堆目录

拿到 uniapp 项目源码后,第一件事永远是看目录结构。答题-答卷这类业务有一个显著特点:管理后台的页面复杂度极高,但 C 端用户实际接触的页面只有三四个——列表页、答题/填写页、结果页。所以不建议按"答题模块""问卷模块"这种业务维度去建目录,那样只会让两个几乎相同的页面各自维护一套逻辑。

我一般会按四层来组织:

src/ pages/ # 只放路由页面 index/ # 试卷/问卷列表 exam/ # 答题/答卷共用的渲染页(核心) result/ # 交卷结果页 components/ question/ # 题目渲染组件(单选/多选/填空/评分/上传) custom/ # 通用业务组件(倒计时条、进度条、签名板) api/ # uni.request 封装 + 按业务分的接口文件 store/ # Pinia / Vuex 状态 useExamStore.js utils/ # 题型映射、判分逻辑、缓存读写、格式化

页面只做路由和生命周期管理,组件只负责渲染和事件抛出,状态全部交给 store,请求层统一封装。这样当后端要从"答完即交"改成"草稿自动保存、断点续答"时,改动基本集中在一个 store 文件和一个 api 文件里,页面代码可以纹丝不动。

2.2 试卷与题目数据模型:把"卷子"设计成 JSON 而不是一堆数据表

答题系统的后端如果用关系型数据库设计,常见做法是 exam 表、question 表、option 表、answer 表各建一套。但一个合格的前端开发拿到源码时,更关注是接口返回的 JSON 结构是否友好。因为 uniapp 端的题目渲染器只认 JSON,后端表设计得再规范,接口不好用前端照样得写转换层。

我建议把卷子接口设计成下面这种嵌套结构,这是答题和答卷能共用渲染器的关键前提:

{ "examId": "E20241001", "title": "前端基础知识测评", "type": "exam", "duration": 1800, "questions": [ { "id": "Q001", "type": "single", "stem": "以下哪个不是 Vue 3 的响应式 API?", "options": ["ref", "reactive", "computed", "createApp"], "answer": 3 }, { "id": "Q002", "type": "multi", "stem": "uniapp 支持哪些条件编译平台?", "options": ["微信小程序", "支付宝小程序", "H5", "App"], "answer": [0, 1, 2, 3] } ] }

字段不多,但每一个都是刻意为之,type 决定渲染器挂哪个组件,options 和答案分离保证渲染层不感知答案,duration 以秒为单位直接喂给倒计时组件。注意我在 JSON 里故意写了"type": "exam",这就是答题和答卷的区别字段——exam 模式要判分、计分、限时;survey 模式不判分、不限时、提交的是用户填写内容而不是选项。数据模型上加一个 type 字段,远比为两套需求各建一张表划算。如果拿到的源码是 Vue2 的 options API 写的,迁移成 Vue3 组合式 API 也只是改写法,数据模型不用动。

2.3 用 Pinia 管理答题状态,而不是用页面 data 或全局缓存

答题系统最头疼的状态有三个:当前是第几题、已选了哪些答案、剩余多少秒。如果这些状态散落在页面 data 里,一旦用户切后台、锁屏、小程序被销毁重建,答题进度就全丢了。

用 Pinia(uniapp 新版 Vue3 项目标配)把状态收拢起来:

// store/useExamStore.js import { defineStore } from 'pinia' export const useExamStore = defineStore('exam', { state: () => ({ paper: null, // 当前卷子完整数据 answers: {}, // 答题记录 { Q001: 2, Q002: [0, 3], Q003: "..." } currentIndex: 0, // 当前第几题 remain: 0, // 剩余秒数 mode: 'exam' // 答题模式:exam / survey }), getters: { allAnswered(state) { if (!state.paper) return false return state.paper.questions.every(q => state.answers[q.id] !== undefined) } }, actions: { setPaper(paper) { this.paper = paper this.answers = {} this.currentIndex = 0 this.remain = paper.duration || 0 this.mode = paper.type || 'exam' }, setAnswer(questionId, value) { this.answers[questionId] = value }, // 关键:写入缓存,防小程序被销毁 saveCache() { uni.setStorageSync(`exam_${this.paper.examId}`, JSON.stringify({ answers: this.answers, currentIndex: this.currentIndex, remain: this.remain })) }, restoreCache(examId) { const cache = uni.getStorageSync(`exam_${examId}`) if (cache) { const data = JSON.parse(cache) this.answers = data.answers this.currentIndex = data.currentIndex this.remain = data.remain return true } return false } } })

答题记录用"题目 id 到答案值"的对象保存,而不是数组,这样随机抽题后答案也能对得上号。.setAnswer里我特意用方法而不是直接改 state,好处是以后要加做题日志、答案校验、自动保存,只需要在这一个方法里扩展。缓存键名带了 examId,用户答两份卷子互不干扰。注意uni.setStorageSync是同步方法,频繁调用会阻塞渲染,实际项目中一般只在翻页、切后台、倒计时归零这三个时机写缓存,千万别在每次 setAnswer 后都同步一次。这个坑在真机上特别明显,Android 低端机用 localStorage 同步写入掉帧非常严重。

3. 答题模式实现:从随机抽题到交卷判分,题目渲染器和计时逻辑这样写

3.1 随机抽题还是固定顺序:源码默认方案和两种改法

大多数答题类小程序的题库和试卷是分离的——库是一张大表,卷子是从库里抽出来的快照。在 uniapp 源码里,抽题逻辑通常放在后端接口里,前端只接收已经是"定稿"的试卷数据。但如果你拿到的源码是把抽题写在客户端的(比如本地题库 json 放在 static 目录),常见做法是:

// utils/paperUtils.js export function pickQuestions(questionBank, count, shuffle = true) { if (shuffle) { // 洗牌后取前 count 道 const arr = [...questionBank] for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]] } return arr.slice(0, count) } return questionBank.slice(0, count) }

用 Fisher-Yates 洗牌而不是sort(() => Math.random() - 0.5),是因为后者在 V8 引擎下的随机分布不均。注意一个问题:一旦试卷定稿,questions 数组的 id 和顺序就不能再动了,否则后面判分、回顾、成绩单都错位。如果只是"每题答案选项顺序乱序",那应该洗 options 数组而不是洗题目数组;如果连题目本身都要乱序,洗完后立刻把完整试卷存一次缓存,交卷时以缓存里的顺序为准,不要重新洗一遍。

3.2 题目渲染器的核心:用一个组件根据 type 分发到不同的题控件

这一节是整个答题系统源码里最值得读的部分。页面只有一个pages/exam/index.vue,里面循环渲染题目组件,题目组件内部再根据 type 分发到具体的单选、多选、填空。

<!-- pages/exam/index.vue 核心片段 --> <template> <view class="exam-page"> <question-display v-for="(q, index) in store.paper.questions" :key="q.id" :question="q" :index="index" :mode="store.mode" @change="onAnswerChange" /> <button type="primary" :disabled="!store.allAnswered" @click="submitExam"> {{ store.mode === 'exam' ? '交卷' : '提交答卷' }} </button> </view> </template> <script setup> import { useExamStore } from '@/store/useExamStore' const store = useExamStore() function onAnswerChange(questionId, value) { store.setAnswer(questionId, value) store.saveCache() } function submitExam() { if (!store.allAnswered) { uni.showToast({ title: '还有题目未作答', icon: 'none' }) return } // 交卷逻辑:路由跳转或直接调用后端接口 } </script>

渲染器做成同一个页面循环渲染所有题目,和常见的"一题一页翻页式"是不同的交互形态。整页滚动适合答卷/问卷场景,用户可以回看、修改;一题一页适合手机答题/考试场景,防止滑动误触、统计单题耗时。源码如果是一题一页,在onAnswerChange里手动调用uni.pageScrollTo或改变 currentIndex 即可切换成滚动模式,不需要重写页面结构。

题型分发组件里最关键的是 uni-app 的动态组件技巧:

<!-- components/question-display.vue --> <template> <view class="question-wrapper"> <view class="question-stem">{{ index + 1 }}. {{ question.stem }}</view> <component :is="componentMap[question.type]" :value="modelValue" :options="question.options" @update:modelValue="emitValue" /> </view> </template> <script setup> import { computed } from 'vue' import SingleChoice from './single-choice.vue' import MultipleChoice from './multiple-choice.vue' import FillBlank from './fill-blank.vue' import RatingScale from './rating-scale.vue' const props = defineProps({ question: Object, index: Number, modelValue: [String, Number, Array] }) const emit = defineEmits(['update:modelValue']) const componentMap = { single: SingleChoice, multi: MultipleChoice, fill: FillBlank, rating: RatingScale } function emitValue(value) { emit('update:modelValue', value) } </script>

使用 Vue 的<component :is>动态组件,是这类系统最值得保持的架构决策。以后要加"滑动打分题""矩阵单选题",只需要新建一个组件、在 componentMap 里注册一行,渲染页和 store 完全不用改。单选和多选组件内部就是radio-groupcheckbox-groupv-for渲染选项,不再展开。注意 v-model 语法在 Vue3 里统一为modelValue + update:modelValue,如果源码是 Vue2 的value + input,改版时记得把子组件里的 props 名和 emit 事件名一起改掉。

3.3 考试倒计时与交卷防抖:哪些参数必须暴露、哪些逻辑要放在后台

限时答题必须做倒计时。但倒计时在 uniapp 里有个第三方库如uni-countdown可引,源码头部也常自己实现。自己写的优势是可控:需要剩余时间写入缓存,以便小程序被杀后恢复。

// utils/timer.js let timer = null export function startCountdown(remain, interval = 1000, onTick, onComplete) { stopCountdown() let seconds = remain onTick(seconds) timer = setInterval(() => { seconds -= 1 if (seconds <= 0) { stopCountdown() onComplete() return } onTick(seconds) }, interval) return { current: () => seconds, stop: stopCountdown } } export function stopCountdown() { if (timer) { clearInterval(timer) timer = null } }

把 interval 也作为参数暴露成 1000 毫秒,是为了某些考试场景需要做"最后 60 秒倒计时高亮",调用方可以单独再起一个高频计时器。真正的倒计时精度问题在于:setInterval在 App 或小程序切后台时会挂起,回前台后还要用"当前时间减去开始时间"校准一遍,而不是继续减 interval 次数。源码里如果没做校准,我一般会在onShow生命周期里重新计算剩余时间:

onShow(() => { const now = Date.now() const lastTick = uni.getStorageSync(`exam_tick_${store.paper?.examId}`) if (lastTick) { const elapsed = Math.floor((now - lastTick) / 1000) store.remain = Math.max(0, store.remain - elapsed) } uni.setStorageSync(`exam_tick_${store.paper?.examId}`, now) })

注意在校准剩余时间时,不能直接remain = total - elapsed,因为total是总考试时长,而用户可能在中途进入过草稿状态。正确逻辑是记录上一次心跳时间和当时的剩余秒数,回来时用差值扣减,这正是上面代码在做的事。交卷按钮建议在点击后立刻置灰并给出 loading 状态,防止用户连续点击两次导致同一份卷子提交两次,后端幂等性不能完全依赖前端,但前端至少要挡住误触。

3.4 判分逻辑写前端还是写后端:答题模式专属的权衡

这是答题和答卷两条路线本质不同的地方。答卷模式根本没有"判分"概念,而答题模式必然要出成绩。具体判断逻辑写在客户端还是服务端,源码里两种做法都有,我不止一次见过前端把答案硬编码在 JS 里、通过比对用户选择来得出分数的实现,这在联调阶段糊弄一下可以,上线就是灾难——所有加密答案等于明文暴露,用户用开发者工具看一眼请求体就能把正确答案挖出来。

正确做法是:接口返回的试卷快照里不含答案字段,用户提交答案后,由服务端判断正误并返回成绩和每题得分。前端只做展示:

// api/exam.js export function submitExam(examId, answers) { // 注意:这里上传的是用户的答题记录,试卷本身已在后端存储 return uni.request({ url: '/api/exam/submit', method: 'POST', data: { examId, answers } }) } // 结果展示 submitExam(store.paper.examId, store.answers).then(res => { const { score, detail, duration } = res.data uni.redirectTo({ url: `/pages/result/result?score=${score}&detail=${encodeURIComponent(JSON.stringify(detail))}` }) })

为什么还要传 duration?因为后端要校验超时提交。前端倒计时归零时只是"停止作答"并自动调用提交,但如果用户把手机时间改回去、或者切后台一段时间再回来,前端本地倒计时可能不准,服务端必须以收到请求的时间减去试卷下发时间来判断是否超时。唯一例外是纯本地演示的小程序,没有后端服务,答案必须放前端,那至少要把答案包进utils/config.js而不是写死在页面里,并用 symbol 混淆一下——这只是拖慢破解速度,谈不上安全。

4. 答卷模式实现:动态表单、草稿自动保存和提交校验的三个差异点

4.1 答卷和答题共用一个渲染器,但校验逻辑必须分开

答卷(survey)和答题(exam)在页面形态上极其相似,都是展示题干、收集用户输入、提交。但两者的产品逻辑有本质差异,这个差异直接决定代码组织方式。答题有标准答案,题型局限在单选、多选、判断、填空;答卷没有标准答案,题型反而更多样——评分题、矩阵题、图片上传、签名、地址选择都很常见。答题关心每道题对错,答卷关心每道题是否必填、是否有格式要求。

在 store 层面加一个mode字段是最省事的分流方案。提交时根据 mode 走两条不同的验证链:

// utils/validator.js export function validateAnswers(questions, answers, mode) { const errors = [] questions.forEach(q => { const value = answers[q.id] if (mode === 'exam') { // 答题模式:只校验有没有作答 if (value === undefined || value === null || value === '') { errors.push({ questionId: q.id, msg: '请完成本题' }) } } else { // 答卷模式:按每个题型的必填规则校验 const required = q.required !== false if (required && isEmpty(value, q.type)) { errors.push({ questionId: q.id, msg: q.errMsg || '本题为必答项' }) } // 题型专属校验,比如手机号、邮箱 if (value && q.validate) { const ok = checkByType(q.validate, value) if (!ok) errors.push({ questionId: q.id, msg: q.validateMsg }) } } }) return errors }

q.requiredq.validate都是题干 JSON 里可选的扩展字段,后端不用为问卷单独建表结构,前端也不用写第二套校验器。这里有个容易踩的细节:答题模式下"选错了"不算校验失败,只有"没选"才算;而答卷模式下"选了什么、填了什么"都算合法输入,唯一要拦截的是空值和不合法格式。把这两件事放在同一个函数里用 mode 分流,比复制一份函数然后改成问卷专用要干净得多。校验不通过时,常见做法是滚动定位到第一道出错题,同时触发表单组件的"错误高亮"状态。

4.2 动态表单渲染的三种题型扩展:评分、图片上传和手写签名

问卷类型的动态表单里,有三类题型在答题系统中很少见,但一旦出现就需要单独写组件。第一是评分题,通常展示 5 星或 10 分刻度;第二是图片上传,需要接入uni.chooseImage把本地图片先传到对象存储再回填 URL;第三是签名板,这类题型的实现依赖 canvas 触摸事件,在微信小程序里和 H5 上的 canvas API 行为差异很大。

评分题实现最简单,只是把答案类型从布尔改成一个数字,但注意分值映射必须和展示刻度保持一致,后端评分阈值一旦变化,前端刻度也要同步。图片上传则复杂得多,核心陷阱是 uni-app 里uni.uploadFile是独立请求,不走uni.request的拦截器,所以 token 要手动放进 header:

function uploadImage(questionId) { uni.chooseImage({ count: 1, success: (res) => { const tempFilePath = res.tempFilePaths[0] uni.uploadFile({ url: `${BASE_URL}/api/upload`, filePath: tempFilePath, name: 'file', header: { Authorization: `Bearer ${getToken()}` }, success: (uploadRes) => { const { url } = JSON.parse(uploadRes.data) // 回填到答题记录,并触发保存 onAnswerChange(questionId, url) } }) } }) }

文件名、文件大小限制、上传进度条这些细节,源码里往往一带而过,但上线后像"传了张 4K 照片导致内存暴涨"、安卓端拍照后图片被旋转 90 度这类问题,都集中在图片上传环节。canvas 手写签名在真机上建议做两件事:触摸事件用touchstart/touchmove/touchend或微信小程序的bindtouchmove,以及画完立刻uni.canvasToTempFilePath转成图片存到存储——不转换的话,切个页面签名就没了。

4.3 草稿自动保存与恢复:断点续答最容易被忽略的边界

答卷类小程序的留存率很大程度上取决于草稿功能。用户填到第 8 题接了个电话,切回来发现要重填,大概率直接卸载。草稿保存的策略我在前面 store 的saveCache一节的实现已经覆盖,这里补充三点关键配置。

第一,存到uni.setStorageSync还是后端草稿接口,取决于卷子长度。超过 20 题的问卷,答案体积可能到几 KB,本地存储没问题,但换手机了草稿不跨端;后端草稿要额外写防抖——用户每改一题就调一次接口,后端压力很大。我一般按"本地缓存实时写、后端草稿 60 秒一次"的双轨方案,本地保即时性,后端保跨端。

第二,恢复草稿的时机要注意。页面onLoad时先查 URL 上有没有draft=1参数,有草稿才恢复;没有参数直接恢复草稿会引发一个 bug——用户明明想很交一份新答卷,却莫名加载了上次的填写记录。恢复后还要在页面顶部显示"已恢复上次未提交的答卷 修改/放弃"的提示条。

第三,草稿的过期策略。公司的满意度调研问卷可能一个季度做一次,三个月后用户看到旧草稿是合理的;但一场"双十一答题抽奖"结束后,草稿必须立即失效。所以草稿缓存里必须带expireAt字段,恢复时先判断:

restoreCache(examId) { const cache = uni.getStorageSync(`exam_${examId}`) if (!cache) return false const data = JSON.parse(cache) if (data.expireAt && Date.now() > data.expireAt) { uni.removeStorageSync(`exam_${examId}`) return false } // 注意:恢复后对答案和缓存进行浅拷贝,避免污染原始数据 this.answers = { ...data.answers } this.currentIndex = data.currentIndex this.remain = data.remain return true }

注意最后把缓存解析后的对象浅拷贝进 store,而不是直接this.answers = data.answers,因为后续任何对页面的改动都可能反向污染下次恢复的缓存数据。这是对象引用传递的经典坑位,答题源码里如果这段写错,用户会出现"答完题提交后,下一份卷子里全是上一份的答案"的诡异问题。

4.4 提交前的校验与埋点:用户乱填和真退出要区分开

答卷提交前的校验除了格式,还有一个容易被忽略的维度——用户是否在认真作答。产品经理经常要求看的指标是"平均作答时长"和"完答率",这两项都依赖前端埋点。我在提交函数里通常会加三个时间戳:打开试卷时间、每题首次作答时间、每题最后修改时间。

function collectTimeline(store) { const timeline = { examId: store.paper.examId, submitTime: Date.now(), duration: store.paper.duration ? store.paper.duration - store.remain : Math.floor((Date.now() - store.openTime) / 1000), perQuestion: store.paper.questions.map(q => ({ qid: q.id, firstSeen: store.seenAt[q.id], answered: store.answers[q.id] !== undefined })) } return timeline }

这段数据跟答案一起 POST 给后端,后端能算出很多有价值的报表:"第三题被 80% 用户反复修改",说明题目有歧义;"第 6 题填写的平均耗时为 X",说明可能需要加说明文案。埋点不用接入专业的埋点 SDK,先把这个数据结构存入后端,等数据量到了再看是否需要 GRPC 上报或异步队列。

5. 源码落地到小程序上架:manifest 配置、动态标题和几个必排的移动端坑

5.1 manifest 和微信小程序设置:页面 json 里必须改的五个字段

一份 unlaipp 项目源码转成微信小程序,最容易出问题的不是代码,而是 manifest 配置和各页面 json 的编译设置。使用 HBuilderX 导入源码后第一件事是右键项目重新识别 manifest.json,然后逐项核对mp-weixin配置:appid 要替换成自己申请的小程序 id 而不能用源码自带的;permission字段里如果调研答卷需要获取位置或相机权限,要同步声明用途说明文案,否则微信后台审核会驳回答卷类小程序提交时引用的隐私协议,隐私弹窗里每一项收集的信息都得对应实际用到的权限接口。

页面 json 里常见的两个坑:一是navigationBarTitleText是写死的,答题页和结果页应该用uni.setNavigationBarTitle动态设置成"第 X 部分 / 已答完,正在生成成绩单",这正好踩中热词里的"小程序动态设置标题"。二是enablePullDownRefresh默认为 false,不要打开——答题中用户下拉就刷新页面的体验极度糟糕,万一后端在答题中途返回新试卷数据,当前进度全毁。源码里如果哪一页开着下拉刷新,先关掉再说。

5.2 分享给好友和扫码进入的落地页必须处理的参数透传

考试答题类小程序最常见的传播场景是"老师发到班级群、学员点小程序卡片进来"。如果分享出去的是通用首页,新用户进来还要自己去找那场考试,转化率极低。我建议在答题页onLoad里统一承接所有入口的参数:

onLoad(options) { // 从分享卡片 / 扫码 / 公众号链接带过来的试卷 id if (options.examId) { this.loadPaper(options.examId, options.draft) } else { // 没有参数,回列表页 uni.redirectTo({ url: '/pages/index/index' }) } }

onLoad(options)里的 options 在微信小程序里来源很多:好友分享的query参数、wx.scanCode扫码后的路径参数、公众号菜单跳转带的参数。draft这个参数在"管理端把链接发给指定员工补填"的场景下很有用,草稿恢复逻辑里我特意判了它。分享按钮的自定义文案和封面也要处理——uni.showShareMenu配合onShareAppMessage动态返回标题,而不能默认截屏发出去,不然对方收到的是一个毫无上下文、标题为"答题"的卡片。

5.3 真机联调的三个高发问题:滚动穿透、字体缩放和安卓 WebView 兼容性

答题页顶部是倒计时条、中间是题目、底部有上一题/下一题按钮的布局,在 H5 或 App 端最容易出现"倒计时条被列表带上去"的滚动穿透。解法是题目列表单独scroll-view滚动,外层页面固定定位;或者拿到源码后检查catchtouchmove是否在固定元素上。这个问题在真机上是强复现的,半屏答题的 H5 页面尤其常见。

第二个高频坑是用户在小程序设置里把字体调大,导致题干和选项文字换行后布局错乱。答题系统里每个题目卡片都应该是独立测量高度,不能用固定高度 + overflow hidden,否则字体一大,选项直接截断。截断之外还有"覆盖",文字溢出遮住下一题的题号,这类 bug 在 iOS 和 Android 上表现不一致,调试时要两套设备对照。

第三个坑是关于安卓端 WebView 的某些 CSS 兼容性。uniapp 编译到 H5 再嵌入 App WebView 时,position: sticky的吸底按钮在部分安卓 WebView 里行为异常,表现为按钮吸底两个像素抖动或者说吸底不生效。低版本 Android WebView 里同时用flexvh单位也可能出问题。如果源码编译到 App 端出现布局异样,优先检查是在微信小程序还是 App WebView 模式复现的,再按条件注释掉相关 CSS 做二分定位。这层兼容调试的投入远大于功能编写,答题页又是交互密集页面,列在这提醒先把基础布局验证一遍再去写复杂动画。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询