安卓用WebView实现表格方式分批加载

29 阅读11分钟

TableWebViewActivity - WebView 表格实现详解

📋 项目概述

TableWebViewActivity 是一个使用 WebView 加载 HTML 实现表格功能的 Android Activity。该实现通过 HTML/CSS/JavaScript 来渲染表格,相比传统的 RecyclerView 实现,具有更好的样式控制能力和更流畅的用户体验。支持大量数据(1000+条)的分批加载,避免性能卡顿。

✨ 功能特性

  • HTML/CSS 表格渲染:使用 WebView 加载 HTML 实现表格,样式控制更灵活
  • 圆角表格设计:整个表格容器具有 16px 圆角,视觉效果更美观
  • 完整表格边框:表格外部有 2px 边框,内部每个单元格都有 1px 边框
  • 白色背景:所有单元格和表头都使用白色背景
  • 高性能分批加载:支持 1000+ 条数据,使用分批渲染机制避免卡顿
  • 动态更新:使用 JavaScript 动态添加/删除表格行,避免页面重新加载导致的抖动
  • 展开/收起功能:支持展开显示全部数据和收起只显示初始数据
  • 流畅的用户体验:加载过程中显示状态提示,支持自动滚动

🏗️ 架构设计

项目结构

TableWebViewActivity.kt
├── 数据管理
   ├── allPersons: MutableList<Person>      # 所有人员数据
   ├── currentDisplayedCount: Int           # 当前显示的数据条数
   └── isExpanded: Boolean                  # 是否已展开
├── UI 组件
   └── webView: WebView                     # WebView 组件
├── 核心方法
   ├── generateData()                       # 生成测试数据
   ├── setupWebView()                       # 配置 WebView
   ├── loadInitialData()                    # 加载初始数据
   ├── generateHtml()                       # 生成 HTML 内容
   ├── expandListInBatches()                # 分批展开数据
   └── collapseList()                       # 收起数据
└── JavaScript 接口
    └── WebAppInterface                      # Android-JavaScript 桥接

数据流程

用户点击按钮
    ↓
JavaScript 调用 Android 接口
    ↓
Android 处理逻辑(展开/收起)
    ↓
分批加载数据
    ↓
JavaScript 动态更新 DOM
    ↓
表格平滑更新(无抖动)

📖 代码详解

1. 类定义和属性

class TableWebViewActivity : AppCompatActivity() {
    
    private lateinit var webView: WebView
    private val allPersons = mutableListOf<Person>()
    private var isExpanded = false
    private var currentDisplayedCount = 0
    private val handler = Handler(Looper.getMainLooper())
    
    companion object {
        private const val INITIAL_ITEM_COUNT = 10      // 初始显示的数据条数
        private const val TOTAL_ITEM_COUNT = 1000      // 总数据量
        private const val BATCH_SIZE = 100             // 每批渲染的数据量
        private const val BATCH_DELAY_MS = 16L        // 每批之间的延迟(约一帧时间)
    }
}

属性说明

  • webView: WebView 组件,用于显示 HTML 内容
  • allPersons: 存储所有人员数据的列表
  • isExpanded: 标记当前是否处于展开状态
  • currentDisplayedCount: 记录当前已显示的数据条数
  • handler: 主线程 Handler,用于异步更新 UI

常量说明

  • INITIAL_ITEM_COUNT: 初始显示 10 条数据
  • TOTAL_ITEM_COUNT: 总共生成 1000 条测试数据
  • BATCH_SIZE: 每批加载 100 条数据
  • BATCH_DELAY_MS: 每批之间延迟 16ms(约一帧时间,60fps)

2. 初始化方法

onCreate()
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_table_webview)
    
    initViews()
    generateData()
    setupWebView()
    loadInitialData()
}

执行流程

  1. 设置布局文件
  2. 初始化视图组件
  3. 生成测试数据
  4. 配置 WebView
  5. 加载初始数据
initViews()
private fun initViews() {
    webView = findViewById(R.id.webView)
}

简单的视图初始化,获取 WebView 引用。

generateData()
private fun generateData() {
    // 生成1000条测试数据
    val names = listOf("张三", "李四", "王五", "赵六", "钱七", "孙八", "周九", "吴十")
    val genders = listOf("男", "女")
    val hobbies = listOf("阅读", "运动", "音乐", "旅游", "摄影", "编程", "绘画", "烹饪")
    
    for (i in 1..TOTAL_ITEM_COUNT) {
        allPersons.add(
            Person(
                id = i,
                name = "${names[i % names.size]}${i}",
                age = 20 + (i % 50),
                gender = genders[i % genders.size],
                hobby = hobbies[i % hobbies.size]
            )
        )
    }
}

功能:生成 1000 条测试数据

数据特点

  • 名字:从预定义列表中循环选择,并添加序号
  • 年龄:20-69 岁之间循环
  • 性别:男/女循环
  • 爱好:从预定义列表中循环选择

3. WebView 配置

setupWebView()
private fun setupWebView() {
    webView.settings.apply {
        javaScriptEnabled = true          // 启用 JavaScript
        domStorageEnabled = true          // 启用 DOM 存储
        loadWithOverviewMode = true       // 以概览模式加载
        useWideViewPort = true           // 使用宽视口
    }
    
    // 添加 JavaScript 接口
    webView.addJavascriptInterface(WebAppInterface(), "Android")
    
    webView.webViewClient = object : WebViewClient() {
        override fun onPageFinished(view: WebView?, url: String?) {
            super.onPageFinished(view, url)
            // 页面加载完成后,可以执行 JavaScript
        }
    }
}

配置说明

  • javaScriptEnabled = true: 必须启用,否则无法执行 JavaScript
  • domStorageEnabled = true: 启用本地存储,用于存储数据
  • loadWithOverviewMode = true: 以概览模式加载,适合移动设备
  • useWideViewPort = true: 使用宽视口,适配不同屏幕尺寸

JavaScript 接口

  • 通过 addJavascriptInterface() 添加接口,允许 JavaScript 调用 Android 方法
  • 接口名称为 "Android",在 JavaScript 中通过 Android.expandTable() 调用

4. HTML 生成

generateHtml()

这是核心方法,生成完整的 HTML 页面:

private fun generateHtml(persons: List<Person>, isExpanded: Boolean): String {
    val tableRows = StringBuilder()
    
    // 表头
    tableRows.append("""
        <tr>
            <th>名字</th>
            <th>年龄</th>
            <th>性别</th>
            <th>爱好</th>
        </tr>
    """.trimIndent())
    
    // 数据行
    persons.forEach { person ->
        tableRows.append("""
            <tr>
                <td>${escapeHtml(person.name)}</td>
                <td>${person.age}</td>
                <td>${escapeHtml(person.gender)}</td>
                <td>${escapeHtml(person.hobby)}</td>
            </tr>
        """.trimIndent())
    }
    
    val buttonText = if (isExpanded) "收起" else "展开更多"
    
    return """...HTML 内容..."""
}

HTML 结构

  1. 表头行:包含"名字"、"年龄"、"性别"、"爱好"四个列
  2. 数据行:循环添加每个 Person 对象的数据
  3. 按钮:根据展开状态显示不同的文本

CSS 样式特点

  • .table-container: 白色背景,16px 圆角,2px 边框
  • table: 100% 宽度,边框合并
  • th, td: 12px 内边距,居中对齐,1px 边框,白色背景
  • .expand-button: Material Design 风格的按钮

JavaScript 函数

  • expandTable(): 处理按钮点击,调用 Android 接口
  • addTableRows(rowsData): 动态添加表格行
  • clearTableRows(): 清除所有数据行(保留表头)
  • updateButton(text, enabled): 更新按钮文本和状态
  • escapeHtml(text): HTML 转义函数
escapeHtml()
private fun escapeHtml(text: String): String {
    return text
        .replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace("\"", "&quot;")
        .replace("'", "&#39;")
}

功能:转义 HTML 特殊字符,防止 XSS 攻击和显示错误

转义规则

  • &&amp;
  • <&lt;
  • >&gt;
  • "&quot;
  • '&#39;

5. JavaScript 接口

WebAppInterface
inner class WebAppInterface {
    @JavascriptInterface
    fun expandTable() {
        handler.post {
            if (isExpanded) {
                collapseList()
            } else {
                expandList()
            }
        }
    }
}

功能:作为 Android 和 JavaScript 之间的桥接

关键点

  • 使用 @JavascriptInterface 注解标记可被 JavaScript 调用的方法
  • 使用 handler.post 确保在主线程执行 UI 操作
  • 根据 isExpanded 状态决定展开或收起

6. 数据加载逻辑

loadInitialData()
private fun loadInitialData() {
    currentDisplayedCount = if (isExpanded) {
        allPersons.size
    } else {
        INITIAL_ITEM_COUNT
    }
    
    val dataPersons = allPersons.take(currentDisplayedCount)
    val html = generateHtml(dataPersons, isExpanded)
    webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null)
}

功能:加载初始数据到 WebView

逻辑

  1. 根据 isExpanded 状态决定显示的数据量
  2. allPersons 中取前 N 条数据
  3. 生成 HTML 内容
  4. 加载到 WebView
expandList()
private fun expandList() {
    // 使用分批异步渲染,避免一次性更新导致卡顿
    expandListInBatches()
}

简单的入口方法,调用分批展开逻辑。

expandListInBatches()

这是核心的分批加载方法:

private fun expandListInBatches() {
    val remainingPersons = allPersons.subList(currentDisplayedCount, allPersons.size)
    
    if (remainingPersons.isEmpty()) {
        // 所有数据已加载完成
        isExpanded = true
        currentDisplayedCount = allPersons.size
        updateButton("收起", true)
        return
    }
    
    // 计算本次要添加的数据
    val batch = remainingPersons.take(BATCH_SIZE)
    currentDisplayedCount += batch.size
    
    // 使用 JavaScript 动态添加行,避免重新加载整个页面
    val rowsJson = batch.joinToString(",", "[", "]") { person ->
        """{"name":"${escapeHtmlForJs(person.name)}","age":${person.age},"gender":"${escapeHtmlForJs(person.gender)}","hobby":"${escapeHtmlForJs(person.hobby)}"}"""
    }
    
    val buttonText = if (currentDisplayedCount >= allPersons.size) "收起" else "正在加载..."
    
    // 在主线程更新UI,但分批进行
    handler.post {
        // 使用 JavaScript 添加行,而不是重新加载整个页面
        webView.evaluateJavascript("addTableRows($rowsJson);", null)
        updateButton(buttonText, currentDisplayedCount >= allPersons.size)
        
        // 当这一批数据渲染完成后,继续下一批
        if (currentDisplayedCount < allPersons.size) {
            // 延迟一帧时间,让UI有时间渲染
            handler.postDelayed({
                expandListInBatches()
            }, BATCH_DELAY_MS)
        } else {
            // 所有数据加载完成
            isExpanded = true
        }
    }
}

执行流程

  1. 获取剩余未显示的数据
  2. 如果已全部加载,更新状态并返回
  3. 取一批数据(BATCH_SIZE 条)
  4. 将数据转换为 JSON 格式
  5. 使用 evaluateJavascript() 调用 JavaScript 函数添加行
  6. 更新按钮状态
  7. 如果还有数据,延迟后继续下一批

关键优化

  • 动态更新:使用 JavaScript 动态添加行,而不是重新加载整个页面
  • 分批加载:每批 100 条,避免一次性加载导致卡顿
  • 延迟渲染:每批之间延迟 16ms,让 UI 有时间渲染
updateButton()
private fun updateButton(text: String, enabled: Boolean) {
    val js = "updateButton('$text', $enabled);"
    webView.evaluateJavascript(js, null)
}

功能:通过 JavaScript 更新按钮文本和启用状态

escapeHtmlForJs()
private fun escapeHtmlForJs(text: String): String {
    return text
        .replace("\\", "\\\\")
        .replace("\"", "\\\"")
        .replace("\n", "\\n")
        .replace("\r", "\\r")
        .replace("\t", "\\t")
}

功能:转义 JavaScript 字符串中的特殊字符

转义规则

  • \\\
  • "\"
  • \n\\n
  • \r\\r
  • \t\\t
collapseList()
private fun collapseList() {
    isExpanded = false
    currentDisplayedCount = INITIAL_ITEM_COUNT
    
    // 使用 JavaScript 清除多余的行,而不是重新加载整个页面
    handler.post {
        // 先清除所有数据行
        webView.evaluateJavascript("clearTableRows();", null)
        
        // 然后只添加初始的10条数据
        val initialPersons = allPersons.take(INITIAL_ITEM_COUNT)
        val rowsJson = initialPersons.joinToString(",", "[", "]") { person ->
            """{"name":"${escapeHtmlForJs(person.name)}","age":${person.age},"gender":"${escapeHtmlForJs(person.gender)}","hobby":"${escapeHtmlForJs(person.hobby)}"}"""
        }
        webView.evaluateJavascript("addTableRows($rowsJson);", null)
        updateButton("展开更多", true)
        
        // 滚动到顶部
        handler.postDelayed({
            webView.evaluateJavascript("window.scrollTo(0, 0);", null)
        }, 100)
    }
}

功能:收起表格,只显示初始的 10 条数据

执行流程

  1. 更新状态标志
  2. 重置显示数量
  3. 使用 JavaScript 清除所有数据行
  4. 添加初始的 10 条数据
  5. 更新按钮状态
  6. 延迟后滚动到顶部

关键优化

  • 动态更新:使用 JavaScript 清除和添加行,避免页面重新加载
  • 平滑过渡:操作在短时间内完成,用户体验流畅

🚀 使用说明

基本使用

  1. 启动 Activity

    val intent = Intent(this, TableWebViewActivity::class.java)
    startActivity(intent)
    
  2. 初始状态

    • 表格显示前 10 条数据
    • 底部显示"展开更多"按钮
  3. 展开操作

    • 点击"展开更多"按钮
    • 按钮文本变为"正在加载...",按钮禁用
    • 数据分批加载(每批 100 条,间隔 16ms)
    • 加载完成后,按钮文本变为"收起",按钮启用
  4. 收起操作

    • 点击"收起"按钮
    • 表格恢复到初始状态(只显示前 10 条)
    • 自动滚动到顶部

自定义配置

修改初始显示数量
companion object {
    private const val INITIAL_ITEM_COUNT = 10  // 修改为你想要的数量
}
修改总数据量
companion object {
    private const val TOTAL_ITEM_COUNT = 1000  // 修改为你想要的数量
}
调整分批加载参数
companion object {
    private const val BATCH_SIZE = 100         // 每批加载的数据量
    private const val BATCH_DELAY_MS = 16L    // 每批之间的延迟(毫秒)
}

参数调优建议

  • BATCH_SIZE:根据设备性能调整
    • 低端设备:50-100
    • 中端设备:100-200
    • 高端设备:200-500
  • BATCH_DELAY_MS:通常设置为 16ms(60fps 的一帧时间)
自定义表格样式

修改 generateHtml() 方法中的 CSS:

.table-container {
    background-color: #FFFFFF;        // 修改背景色
    border-radius: 16px;             // 修改圆角大小
    border: 2px solid #CCCCCC;       // 修改边框样式
}
自定义数据源

修改 generateData() 方法,从网络或数据库加载:

private fun generateData() {
    // 从网络加载
    // viewModel.loadData { data ->
    //     allPersons.addAll(data)
    //     loadInitialData()
    // }
    
    // 或从数据库加载
    // allPersons.addAll(database.getAllPersons())
}

⚡ 性能优化

1. 分批加载

原理:将大量数据分成小批次,每批之间延迟一帧时间

优势

  • 避免一次性加载导致 UI 线程阻塞
  • 保持 60fps 的流畅帧率
  • 用户可以随时看到加载进度

实现

val batch = remainingPersons.take(BATCH_SIZE)
handler.postDelayed({
    expandListInBatches()
}, BATCH_DELAY_MS)

2. JavaScript 动态更新

原理:使用 JavaScript DOM 操作,而不是重新加载整个 HTML 页面

优势

  • 避免页面重新加载导致的抖动
  • 保持滚动位置
  • 更快的更新速度

实现

webView.evaluateJavascript("addTableRows($rowsJson);", null)

3. HTML 转义

原理:转义特殊字符,防止 XSS 攻击和显示错误

实现

private fun escapeHtml(text: String): String {
    return text
        .replace("&", "&amp;")
        .replace("<", "&lt;")
        // ...
}

4. 主线程操作

原理:所有 UI 更新都在主线程执行

实现

handler.post {
    // UI 更新操作
}

🔧 技术细节

JavaScript 接口

添加接口
webView.addJavascriptInterface(WebAppInterface(), "Android")

注意事项

  • 接口名称 "Android" 在 JavaScript 中通过 Android.methodName() 调用
  • 方法必须使用 @JavascriptInterface 注解
  • 方法必须在主线程执行 UI 操作
调用 JavaScript
webView.evaluateJavascript("addTableRows($rowsJson);", null)

参数说明

  • 第一个参数:JavaScript 代码字符串
  • 第二个参数:回调函数(可选)

HTML 生成

字符串模板

使用 Kotlin 的三引号字符串(triple-quoted string)生成 HTML:

return """
    <!DOCTYPE html>
    <html>
    ...
    </html>
""".trimIndent()

优势

  • 保持代码格式
  • 易于阅读和维护
  • 支持多行字符串
字符串插值

在 HTML 中插入动态内容:

<td>${escapeHtml(person.name)}</td>

注意事项

  • 必须转义 HTML 特殊字符
  • 使用 escapeHtml() 函数

数据转换

JSON 格式

将 Person 对象转换为 JSON 格式:

val rowsJson = batch.joinToString(",", "[", "]") { person ->
    """{"name":"${escapeHtmlForJs(person.name)}","age":${person.age},...}"""
}

格式

[
    {"name":"张三1","age":20,"gender":"男","hobby":"阅读"},
    {"name":"李四2","age":21,"gender":"女","hobby":"运动"},
    ...
]

📝 注意事项

1. 线程安全

  • 所有 UI 操作必须在主线程执行
  • 使用 Handler.post() 确保线程安全

2. 内存管理

  • 大量数据时注意内存使用
  • 考虑使用分页加载或虚拟滚动

3. 网络安全

  • 如果从网络加载 HTML,注意 XSS 攻击
  • 始终转义用户输入

4. WebView 配置

  • 必须启用 JavaScript
  • 生产环境考虑禁用文件访问等安全设置

5. 性能考虑

  • 避免一次性加载过多数据
  • 使用分批加载机制
  • 考虑使用虚拟滚动(Virtual Scrolling)

🐛 常见问题

Q: 为什么使用 WebView 而不是 RecyclerView?

A: WebView 的优势:

  • 样式控制更灵活(CSS)
  • 表格渲染更稳定
  • 跨平台兼容性好
  • 可以实现复杂的表格样式

Q: 如何解决页面抖动问题?

A: 使用 JavaScript 动态更新 DOM,而不是重新加载整个页面:

webView.evaluateJavascript("addTableRows($rowsJson);", null)

Q: 如何优化大量数据的加载?

A:

  1. 使用分批加载(BATCH_SIZE)
  2. 每批之间延迟(BATCH_DELAY_MS)
  3. 使用虚拟滚动(Virtual Scrolling)

Q: JavaScript 接口不工作怎么办?

A: 检查:

  1. 是否启用了 JavaScript:javaScriptEnabled = true
  2. 方法是否有 @JavascriptInterface 注解
  3. 接口名称是否正确
  4. 是否在主线程调用

Q: 如何自定义表格样式?

A: 修改 generateHtml() 方法中的 CSS 部分:

.table-container {
    /* 自定义样式 */
}

Q: 如何处理特殊字符?

A: 使用转义函数:

  • HTML 转义:escapeHtml()
  • JavaScript 转义:escapeHtmlForJs()

📄 相关文件

  • TableWebViewActivity.kt - 主 Activity 文件
  • activity_table_webview.xml - 布局文件
  • Person.kt - 数据模型

🔗 相关文档

👨‍💻 全部代码实现

TableWebViewActivity 项目

        ```
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivity

class TableWebViewActivity : AppCompatActivity() {

private lateinit var webView: WebView
private val allPersons = mutableListOf<Person>()
private var isExpanded = false
private var currentDisplayedCount = 0
private val handler = Handler(Looper.getMainLooper())

companion object {
    private const val INITIAL_ITEM_COUNT = 10
    private const val TOTAL_ITEM_COUNT = 1000
    private const val BATCH_SIZE = 100 // 每批渲染的数据量
    private const val BATCH_DELAY_MS = 16L // 每批之间的延迟(约一帧时间)
}

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_table_webview)
    
    initViews()
    generateData()
    setupWebView()
    loadInitialData()
}

private fun initViews() {
    webView = findViewById(R.id.webView)
}

private fun generateData() {
    // 生成1000条测试数据
    val names = listOf("张三", "李四", "王五", "赵六", "钱七", "孙八", "周九", "吴十")
    val genders = listOf("男", "女")
    val hobbies = listOf("阅读", "运动", "音乐", "旅游", "摄影", "编程", "绘画", "烹饪")
    
    for (i in 1..TOTAL_ITEM_COUNT) {
        allPersons.add(
            Person(
                id = i,
                name = "${names[i % names.size]}${i}",
                age = 20 + (i % 50),
                gender = genders[i % genders.size],
                hobby = hobbies[i % hobbies.size]
            )
        )
    }
}

private fun setupWebView() {
    webView.settings.apply {
        javaScriptEnabled = true
        domStorageEnabled = true
        loadWithOverviewMode = true
        useWideViewPort = true
    }
    
    // 添加 JavaScript 接口
    webView.addJavascriptInterface(WebAppInterface(), "Android")
    
    webView.webViewClient = object : WebViewClient() {
        override fun onPageFinished(view: WebView?, url: String?) {
            super.onPageFinished(view, url)
            // 页面加载完成后,可以执行 JavaScript
        }
    }
}

private fun loadInitialData() {
    currentDisplayedCount = if (isExpanded) {
        allPersons.size
    } else {
        INITIAL_ITEM_COUNT
    }
    
    val dataPersons = allPersons.take(currentDisplayedCount)
    val html = generateHtml(dataPersons, isExpanded)
    webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null)
}

private fun generateHtml(persons: List<Person>, isExpanded: Boolean): String {
    val tableRows = StringBuilder()
    
    // 表头
    tableRows.append("""
        <tr>
            <th>名字</th>
            <th>年龄</th>
            <th>性别</th>
            <th>爱好</th>
        </tr>
    """.trimIndent())
    
    // 数据行
    persons.forEach { person ->
        tableRows.append("""
            <tr>
                <td>${escapeHtml(person.name)}</td>
                <td>${person.age}</td>
                <td>${escapeHtml(person.gender)}</td>
                <td>${escapeHtml(person.hobby)}</td>
            </tr>
        """.trimIndent())
    }
    
    val buttonText = if (isExpanded) "收起" else "展开更多"
    
    return """
        <!DOCTYPE html>
        <html>
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <style>
                * {
                    margin: 0;
                    padding: 0;
                    box-sizing: border-box;
                }
                body {
                    font-family: Arial, sans-serif;
                    padding: 16px;
                    background-color: #f5f5f5;
                }
                .table-container {
                    background-color: #FFFFFF;
                    border-radius: 16px;
                    overflow: hidden;
                    border: 2px solid #CCCCCC;
                    margin-bottom: 16px;
                }
                table {
                    width: 100%;
                    border-collapse: collapse;
                    background-color: #FFFFFF;
                }
                th, td {
                    padding: 12px;
                    text-align: center;
                    border: 1px solid #CCCCCC;
                    background-color: #FFFFFF;
                }
                th {
                    font-weight: bold;
                }
                .expand-button {
                    width: calc(100% - 32px);
                    padding: 16px;
                    margin: 16px;
                    background-color: #6200EE;
                    color: white;
                    border: none;
                    border-radius: 8px;
                    font-size: 16px;
                    cursor: pointer;
                }
                .expand-button:disabled {
                    background-color: #CCCCCC;
                    cursor: not-allowed;
                }
                .expand-button:active {
                    background-color: #3700B3;
                }
            </style>
        </head>
        <body>
            <div class="table-container">
                <table id="dataTable">
                    $tableRows
                </table>
            </div>
            <button class="expand-button" id="expandButton" onclick="expandTable()">
                $buttonText
            </button>
            <script>
                function expandTable() {
                    var button = document.getElementById('expandButton');
                    button.disabled = true;
                    button.textContent = '正在加载...';
                    Android.expandTable();
                }
                
                function addTableRows(rowsData) {
                    var table = document.getElementById('dataTable');
                    rowsData.forEach(function(row) {
                        var tr = document.createElement('tr');
                        tr.innerHTML = '<td>' + escapeHtml(row.name) + '</td>' +
                                     '<td>' + row.age + '</td>' +
                                     '<td>' + escapeHtml(row.gender) + '</td>' +
                                     '<td>' + escapeHtml(row.hobby) + '</td>';
                        table.appendChild(tr);
                    });
                }
                
                function clearTableRows() {
                    var table = document.getElementById('dataTable');
                    // 保留表头,删除所有数据行
                    while (table.rows.length > 1) {
                        table.deleteRow(1);
                    }
                }
                
                function updateButton(text, enabled) {
                    var button = document.getElementById('expandButton');
                    button.textContent = text;
                    button.disabled = !enabled;
                }
                
                function escapeHtml(text) {
                    var map = {
                        '&': '&amp;',
                        '<': '&lt;',
                        '>': '&gt;',
                        '"': '&quot;',
                        "'": '&#39;'
                    };
                    return text.replace(/[&<>"']/g, function(m) { return map[m]; });
                }
            </script>
        </body>
        </html>
    """.trimIndent()
}

private fun escapeHtml(text: String): String {
    return text
        .replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace(""", "&quot;")
        .replace("'", "&#39;")
}

inner class WebAppInterface {
    @JavascriptInterface
    fun expandTable() {
        handler.post {
            if (isExpanded) {
                collapseList()
            } else {
                expandList()
            }
        }
    }
}

private fun expandList() {
    // 使用分批异步渲染,避免一次性更新导致卡顿
    expandListInBatches()
}

private fun expandListInBatches() {
    val remainingPersons = allPersons.subList(currentDisplayedCount, allPersons.size)
    
    if (remainingPersons.isEmpty()) {
        // 所有数据已加载完成
        isExpanded = true
        currentDisplayedCount = allPersons.size
        updateButton("收起", true)
        return
    }
    
    // 计算本次要添加的数据
    val batch = remainingPersons.take(BATCH_SIZE)
    currentDisplayedCount += batch.size
    
    // 使用 JavaScript 动态添加行,避免重新加载整个页面
    val rowsJson = batch.joinToString(",", "[", "]") { person ->
        """{"name":"${escapeHtmlForJs(person.name)}","age":${person.age},"gender":"${escapeHtmlForJs(person.gender)}","hobby":"${escapeHtmlForJs(person.hobby)}"}"""
    }
    
    val buttonText = if (currentDisplayedCount >= allPersons.size) "收起" else "正在加载..."
    
    // 在主线程更新UI,但分批进行
    handler.post {
        // 使用 JavaScript 添加行,而不是重新加载整个页面
        webView.evaluateJavascript("addTableRows($rowsJson);", null)
        updateButton(buttonText, currentDisplayedCount >= allPersons.size)
        
        // 当这一批数据渲染完成后,继续下一批
        if (currentDisplayedCount < allPersons.size) {
            // 延迟一帧时间,让UI有时间渲染
            handler.postDelayed({
                expandListInBatches()
            }, BATCH_DELAY_MS)
        } else {
            // 所有数据加载完成
            isExpanded = true
        }
    }
}

private fun updateButton(text: String, enabled: Boolean) {
    val js = "updateButton('$text', $enabled);"
    webView.evaluateJavascript(js, null)
}

private fun escapeHtmlForJs(text: String): String {
    return text
        .replace("\", "\\")
        .replace(""", "\"")
        .replace("\n", "\n")
        .replace("\r", "\r")
        .replace("\t", "\t")
}

private fun collapseList() {
    isExpanded = false
    currentDisplayedCount = INITIAL_ITEM_COUNT
    
    // 使用 JavaScript 清除多余的行,而不是重新加载整个页面
    handler.post {
        // 先清除所有数据行
        webView.evaluateJavascript("clearTableRows();", null)
        
        // 然后只添加初始的10条数据
        val initialPersons = allPersons.take(INITIAL_ITEM_COUNT)
        val rowsJson = initialPersons.joinToString(",", "[", "]") { person ->
            """{"name":"${escapeHtmlForJs(person.name)}","age":${person.age},"gender":"${escapeHtmlForJs(person.gender)}","hobby":"${escapeHtmlForJs(person.hobby)}"}"""
        }
        webView.evaluateJavascript("addTableRows($rowsJson);", null)
        updateButton("展开更多", true)
        
        // 滚动到顶部
        handler.postDelayed({
            webView.evaluateJavascript("window.scrollTo(0, 0);", null)
        }, 100)
    }
}
}

---

**最后更新**:2025年12月27日