ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android WebView HTML题库离线答题系统实战

Android WebView HTML题库离线答题系统实战

简介:这是一套面向Android开发初学者与进阶者的HTML整合型答题APP完整源码,适用于移动应用开发实践、课程设计或技术栈融合学习场景。项目采用Java与Kotlin双语言协同开发,前端界面深度集成HTML资源,兼顾原生性能与动态内容展示能力,可支撑在线答题、题库加载、用户交互等核心功能。压缩包共786个文件,总大小47.87MB,涵盖182个Java业务逻辑文件、8个Kotlin扩展模块、173个XML布局与配置文件、305个PNG/WebP图标与界面素材、18个JSON题库与配置数据、9个Gradle构建脚本,以及35个aar广告与SDK依赖库(含百度、广点通、穿山甲等主流接入包),结构清晰、模块可拆解。目前已有347人学习下载,开发者可直接运行调试、分析多语言协作模式、研究HTML嵌入WebView的交互逻辑,并参考登录(hm_login)、工作区(wk)等目录划分理解典型Android工程架构设计。

1. 这不是个“Java+Kotlin混合写App”的炫技项目:它解决的是教育类轻量级答题场景里,HTML题库动态加载、本地离线渲染、双向数据绑定与状态持久化的落地断点问题

你有没有遇到过这样的现场?某高校教务系统要快速上线一个随堂小测模块,要求不依赖后端服务、能导出为单文件、支持富文本题目(含公式、图片、音频提示)、学生作答后自动批改并生成错题本——但开发周期只有5天,团队里Android工程师熟悉Kotlin,Web前端同事擅长HTML/CSS/JS,而题库管理员只会用Word编辑题目、导出为HTML片段。这时候,“基于Java和Kotlin的HTML整合答题APP”就不是一句空话,而是把三股力量拧成一股绳的工程锚点:Java层做资源管理与生命周期兜底,Kotlin层封装交互逻辑与状态机,HTML层承载题干渲染与用户输入界面。它不追求全栈重构,而是用最小侵入方式,让一份HTML题库在Android端“活起来”。适合正在做校本测评工具、企业内训答题系统、或需要快速验证题型交互原型的中小团队。如果你正卡在“WebView加载HTML后无法回传答案”“本地JS调Java方法总失败”“Kotlin协程更新UI时HTML状态不同步”这类具体问题上,这篇笔记就是为你写的血泪复盘。


2. 为什么必须用Java+Kotlin双语言协同?——从WebView通信机制倒推技术选型

2.1 Android WebView的通信本质:Java是桥基,Kotlin是调度中枢

WebView本身不认Kotlin,所有JS调用原生能力的入口,最终都落在WebView.addJavascriptInterface()注册的Java对象上。这是硬性约束。但若全用Java写业务逻辑,会丢失Kotlin对协程、扩展函数、密封类状态管理的优势。因此合理分工是:

  • Java层:仅暴露极简接口,如submitAnswer(String qid, String value)、getQuestionData(String qid),负责线程安全、异常捕获、JNI兼容性兜底;
  • Kotlin层:通过@JavascriptInterface代理类包装Java接口,用viewModelScope.launch驱动异步提交、用StateFlow同步答题进度、用sealed interface建模题型状态(SingleChoice,FillBlank,AudioListen);
  • HTML层:通过window.AndroidBridge.submitAnswer(...)触发调用,所有DOM事件监听器只负责采集输入,不处理业务逻辑。

提示:不要在Java接口里直接操作View或LiveData——这会导致WebView线程与主线程冲突。所有UI更新必须交由Kotlin ViewModel通过postValue()完成。

2.2 题库HTML的结构契约:约定即协议,避免“JS找不到Java方法”的玄学翻车

我们强制题库HTML遵守三项契约,否则Kotlin层无法可靠解析:

  1. 每道题包裹在<div class="question">// inject.js —— 注入到WebView的初始化脚本 document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('button[data-action="submit"]').forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); const qid = btn.closest('.question').dataset.qid; const inputs = btn.closest('.question').querySelectorAll('[data-input-type]'); let answer = ''; inputs.forEach(input => { if (input.type === 'radio' && input.checked) answer = input.value; if (input.type === 'text' || input.type === 'textarea') answer = input.value; }); window.AndroidBridge.submitAnswer(qid, answer); // 调用Java接口 }); }); });

    这段JS不依赖任何框架,纯原生DOM操作,确保在Android 5.0+所有WebView内核中稳定执行。关键点在于:JS只负责采集和转发,不判断对错、不存本地、不跳转页面——这些全部交给Kotlin ViewModel处理。

    2.3 Kotlin ViewModel如何承接JS调用?——用代理模式解耦桥接逻辑

    直接在Java接口里写业务逻辑会污染桥接层。我们采用代理模式:Java层只做参数校验和线程切换,Kotlin层实现真实业务:

    // JavaBridge.java —— 纯桥接,无业务 public class JavaBridge { private final QuestionManager manager; // Kotlin对象,通过构造注入 public JavaBridge(QuestionManager manager) { this.manager = manager; } @JavascriptInterface public void submitAnswer(String qid, String value) { // 1. 参数非空校验 if (qid == null || qid.trim().isEmpty()) return; // 2. 切换到主线程执行Kotlin逻辑 new Handler(Looper.getMainLooper()).post(() -> manager.handleAnswerSubmission(qid, value) ); } }
    // QuestionManager.kt —— 真实业务在此 class QuestionManager( private val repository: QuestionRepository, private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) { private val _answerState = MutableStateFlow<Map<String, String>>(emptyMap()) val answerState: StateFlow<Map<String, String>> = _answerState.asStateFlow() fun handleAnswerSubmission(qid: String, value: String) { viewModelScope.launch { // 1. 保存到本地数据库(Room) repository.upsertAnswer(qid, value) // 2. 更新状态流,触发UI重绘 _answerState.value = repository.getAllAnswers() // 3. 若为最后一题,触发自动批改 if (repository.isLastQuestion(qid)) { autoGrade() } } } }

    这种分层让Java桥接层代码量控制在200行以内,Kotlin业务层可自由使用协程、Flow、Hilt依赖注入,且单元测试可完全脱离WebView环境。


    3. HTML题库如何与Android资源目录无缝整合?——本地化路径、离线加载与增量更新方案

    3.1 资源目录结构设计:让HTML、CSS、JS、媒体文件各归其位

    我们放弃将题库打包进assets目录的粗放做法,改用res/raw/+res/drawable/+res/xml/三级结构,原因有三:

    • res/raw/支持按配置限定符(如raw-zh/,raw-en/)提供多语言题库;
    • res/drawable/可直接被WebView通过file:///android_res/drawable/xxx.png引用,无需额外拷贝;
    • res/xml/存放题库元数据(如questions_index.xml),用于启动时快速扫描可用题库。

    标准目录结构如下:

    app/src/main/res/ ├── raw/ │ ├── quiz_math_v1.html // 主题题库HTML │ ├── quiz_physics_v2.html │ └── common_styles.css // 全局样式,被所有HTML import ├── drawable/ │ ├── formula_integral.png │ ├── audio_icon.svg │ └── chart_bar.jpg └── xml/ └── questions_index.xml // 定义题库版本、题量、适用年级

    questions_index.xml示例:

    <?xml version="1.0" encoding="utf-8"?> <quiz-index> <quiz id="math_v1" name="高中数学基础题" version="1.2" grade="10-12" question-count="25"/> <quiz id="physics_v2" name="力学专项训练" version="2.0" grade="11" question-count="30"/> </quiz-index>

    3.2 WebView离线加载HTML:绕过file://协议限制的可靠路径

    Android 7.0+禁止WebView直接加载file://协议下的JS/CSS(出于安全策略)。正确做法是:

    1. 将HTML文件读取为字符串;
    2. 用WebView.loadDataWithBaseURL()加载,并指定baseURL为file:///android_res/;
    3. 所有相对路径(如<link href="common_styles.css">)将自动解析为file:///android_res/raw/common_styles.css。
    private fun loadQuizHtml(context: Context, webView: WebView, quizId: String) { val htmlResId = context.resources.getIdentifier(quizId, "raw", context.packageName) val htmlContent = context.resources.openRawResource(htmlResId).use { it.readBytes() }.toString(Charsets.UTF_8) webView.settings.apply { javaScriptEnabled = true domStorageEnabled = true databaseEnabled = true setAppCacheEnabled(true) } // 关键:baseURL指向android_res根目录 webView.loadDataWithBaseURL( "file:///android_res/", htmlContent, "text/html", "UTF-8", null ) }

    注意:loadDataWithBaseURL的baseUrl参数必须以file:///开头,且末尾带斜杠,否则CSS/JS路径解析失败。

    3.3 增量题库更新:用res/raw的编译期校验替代运行时MD5比对

    很多方案用SharedPreferences存题库版本号,每次启动检查网络更新——但这对离线场景无效。我们利用Android资源编译机制:

    • 每次题库HTML更新,同步修改res/xml/questions_index.xml中的version属性;
    • App启动时读取XML,对比当前APK内版本与本地数据库记录的版本;
    • 若不一致,则清空旧题库表,重新解析HTML并插入新数据。

    这样无需网络、无需额外存储、无版本漂移风险。因为res/目录内容在APK构建时已固化,resources.getIdentifier()能100%定位到最新资源。


    4. 常见问题排查:那些让开发者连续三天睡不着的WebView通信黑匣子

    4.1 现象:JS调用window.AndroidBridge.submitAnswer()无反应,Logcat无任何日志

    原因:addJavascriptInterface()未在WebView.setWebViewClient()之后调用,导致WebView尚未完成初始化,桥接对象未注册成功。
    解决:严格按顺序执行——先设置WebViewClient,再调用addJavascriptInterface(),最后loadUrl()或loadDataWithBaseURL()。

    webView.webViewClient = WebViewClient() // 必须最先设 webView.addJavascriptInterface(JavaBridge(manager), "AndroidBridge") // 第二步 loadQuizHtml(context, webView, "quiz_math_v1") // 最后一步

    4.2 现象:HTML中<img src="@drawable/icon_correct">显示空白,但file:///android_res/drawable/icon_correct.png在浏览器中可访问

    原因:@drawable/是Android资源引用语法,仅在XML布局中有效;WebView中必须用file:///android_res/drawable/icon_correct.png格式,且文件名需全小写+下划线(Android资源命名规范)。
    解决:题库HTML中所有图片路径统一改为file:///android_res/drawable/xxx.png,并在构建时用Gradle Task校验res/drawable/下是否存在对应文件。

    4.3 现象:Kotlin层handleAnswerSubmission()被调用,但StateFlow更新后UI未刷新

    原因:WebView默认在独立线程渲染,其回调线程并非主线程,而StateFlow.collectAsStateWithLifecycle()只监听主线程的LifecycleOwner。
    解决:在handleAnswerSubmission()中显式切回主线程更新StateFlow:

    fun handleAnswerSubmission(qid: String, value: String) { viewModelScope.launch { repository.upsertAnswer(qid, value) // 关键:用withContext(Dispatchers.Main)确保更新在主线程 withContext(Dispatchers.Main) { _answerState.value = repository.getAllAnswers() } } }

    4.4 现象:同一台设备,Android 10正常,Android 12点击提交按钮后整个WebView白屏

    原因:Android 12启用了StrictMode,禁止在WebView中执行耗时JS操作(如遍历大量DOM节点)。题库HTML中存在document.querySelectorAll('.question').forEach(...)未加节流。
    解决:在注入JS中添加requestIdleCallback降级:

    if ('requestIdleCallback' in window) { requestIdleCallback(() => bindSubmitEvents()); } else { setTimeout(bindSubmitEvents, 0); }

    4.5 现象:学生切换题目时,WebView反复loadDataWithBaseURL()导致内存泄漏,OOM崩溃

    原因:每次加载新题库都新建WebView实例,旧实例未destroy(),且JS持有Java对象引用。
    解决:复用单个WebView实例,用webView.clearCache(true)+webView.clearHistory()清理状态,再loadDataWithBaseURL()重载新HTML。

    fun switchToQuiz(webView: WebView, newQuizId: String) { webView.clearCache(true) webView.clearHistory() webView.loadUrl("about:blank") // 清空当前页 loadQuizHtml(context, webView, newQuizId) // 重载新题库 }

    5. 状态持久化与自动批改:用Room+SQLite实现离线判题核心链路

    5.1 题目元数据与答案表结构设计:兼顾扩展性与查询效率

    我们定义两张核心表,用Room注解声明,避免手写SQL错误:

    @Entity(tableName = "questions") data class QuestionEntity( @PrimaryKey val qid: String, val title: String, val type: QuestionType, // enum: SINGLE_CHOICE, FILL_BLANK, AUDIO_LISTEN val contentHtml: String, // 题干HTML片段,不含答案区 val correctAnswer: String, // 标准答案,JSON格式存储多选/填空等复杂结构 val difficulty: Int, // 1-5 val tags: String // 逗号分隔,如"math,algebra,grade10" ) @Entity(tableName = "answers") data class AnswerEntity( @PrimaryKey val qid: String, val studentAnswer: String, val isCorrect: Boolean, val timestamp: Long ) // Room DAO接口 @Dao interface QuestionDao { @Query("SELECT * FROM questions WHERE qid = :qid") suspend fun getQuestion(qid: String): QuestionEntity? @Query("SELECT * FROM answers WHERE qid = :qid") suspend fun getAnswer(qid: String): AnswerEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAnswer(answer: AnswerEntity) }

    关键设计点:

    • correctAnswer字段存JSON而非明文,支持未来扩展(如填空题多个空、多选题多个正确选项);
    • tags字段用逗号分隔,避免建中间表,用LIKE '%algebra%'即可模糊查,牺牲一点范式换查询速度;
    • AnswerEntity不存学生ID,因本APP为单用户离线场景,ID由App全局唯一标识(如Settings.Global.getString(content.contentResolver, Settings.Global.ANDROID_ID))。

    5.2 自动批改引擎:用Kotlin DSL定义判题规则,拒绝硬编码

    判题逻辑不能写死在if-else里。我们用Kotlin DSL定义规则引擎,每个题型对应一个GradingRule:

    sealed interface GradingRule { fun grade(correct: String, student: String): Boolean } object SingleChoiceRule : GradingRule { override fun grade(correct: String, student: String): Boolean = correct == student } object FillBlankRule : GradingRule { override fun grade(correct: String, student: String): Boolean { val correctList = JSONArray(correct).toList().map { it.toString().trim() } val studentList = student.split("\\s*,\\s*".toRegex()).map { it.trim() } return correctList.size == studentList.size && correctList.zip(studentList).all { (c, s) -> c.equals(s, ignoreCase = true) } } } // 注册中心 val GRADING_RULES = mapOf( QuestionType.SINGLE_CHOICE to SingleChoiceRule, QuestionType.FILL_BLANK to FillBlankRule, QuestionType.AUDIO_LISTEN to AudioListenRule )

    批改时只需一行调用:

    fun autoGrade() { viewModelScope.launch { val allAnswers = repository.getAllAnswers() val results = allAnswers.map { (qid, answer) -> val question = repository.getQuestion(qid) ?: return@map null val rule = GRADING_RULES[question.type] ?: return@map null Triple(qid, answer.studentAnswer, rule.grade(question.correctAnswer, answer.studentAnswer)) }.filterNotNull() // 更新答案表中的isCorrect字段 results.forEach { (qid, _, isCorrect) -> repository.updateIsCorrect(qid, isCorrect) } } }

    5.3 错题本生成:用SQL窗口函数实现高频错题聚类

    学生做完一套题,常问:“我哪类题错得最多?” 我们不用客户端遍历统计,而用Room支持的SQLite 3.25+窗口函数直接查:

    @Query(""" SELECT tag, COUNT(*) as error_count, ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM answers WHERE isCorrect = 0), 1) as percentage FROM ( SELECT DISTINCT qid, tag FROM questions JOIN answers ON questions.qid = answers.qid WHERE answers.isCorrect = 0 AND questions.tags LIKE '%' || :tagFilter || '%' ) AS tagged_errors CROSS JOIN json_each(tagged_errors.tags) AS tags GROUP BY tag ORDER BY error_count DESC LIMIT 5 """) suspend fun getTopErrorTags(tagFilter: String = ""): List<ErrorTagSummary> data class ErrorTagSummary( val tag: String, val errorCount: Long, val percentage: Double )

    此查询直接返回“代数”“函数”“几何”等标签的错误频次与占比,前端列表渲染即可,无额外计算开销。


    6. 终极技巧:用Gradle构建时预处理HTML,把“人工校验题库”变成“零成本自动化流水线”

    6.1 构建期HTML静态检查:拦截90%题库格式错误

    我们编写一个Gradle Task,在assembleDebug前扫描res/raw/下所有HTML文件,自动检测:

    • 是否存在未闭合的<div class="question">;
    • 每道题是否包含>// app/build.gradle tasks.register('validateQuizHtml', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = 'com.example.quiz.HtmlValidator' args = ["${project.projectDir}/src/main/res/raw/"] } preBuild.dependsOn validateQuizHtml

      对应的HtmlValidator.java用Jsoup解析HTML,输出结构化错误报告:

      ERROR: quiz_math_v1.html line 42 - <div class="question"> missing>// generateQuizIndex.gradle def rawDir = fileTree(dir: "${project.projectDir}/src/main/res/raw/", include: "*.html") def indexXml = new XmlSlurper().parseText('<quiz-index></quiz-index>') rawDir.each { file -> def content = file.text def qid = (content =~ /data-qid="([^"]+)"/)[0][1] ?: "unknown" def name = (content =~ /<title>([^<]+)<\/title>/)[0][1] ?: "未命名题库" def version = (content =~ /<!-- version: ([^\\s]+) -->/)[0][1] ?: "1.0" indexXml.appendNode { quiz(id: qid, name: name, version: version, questionCount: countQuestions(content)) } } def writer = new FileWriter("${project.projectDir}/src/main/res/xml/questions_index.xml") new XmlNodePrinter(new PrintWriter(writer)).print(indexXml)

      只要在HTML头部加一行注释<!-- version: 2.1 -->,构建时就自动生成完整索引——从此题库增删不再需要人工改XML。

      6.3 我的习惯:用Git Hooks做题库提交前校验,防患于未然

      在.git/hooks/pre-commit里加入一行:

      # 检查res/raw/下HTML是否通过validateQuizHtml ./gradlew validateQuizHtml --no-daemon || exit 1

      这样,开发者git commit时若题库有误,立刻失败并提示错误位置。不需要培训、不需要文档、不需要监督——流程即规范。

      这套组合拳下来,题库从Word编辑→导出HTML→放入res/raw/→提交Git→APK生成,全程无人工干预点。某高校教师自己上传了32套数学题库,整个过程没找过一次开发,因为所有错误都在她敲下git commit时就被拦住了。

      希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进