Android高含金量实战:音频文本 HTML 标签解析 + 段落分组 + 自定义圆角 SpanUI 渲染

​编辑

在 Android 开发中,如果你接到一个需求:服务端返回带 <em><span> 等 HTML 标签的文本,要求按段落展示,且特定标签需要圆角背景 并能点击 ,同时段落本身也要支持点击选中------你会怎么做?Html.fromHtml 无法自定义圆角背景,ClickableSpan 又容易和 RecyclerView 的 item 点击冲突。本文记录一套基于自定义 ReplacementSpan + 标签解析的完整实现方案。

本项目采用mvvm架构 使用kotlin语言,这篇属于音频的数据处理和ui展示部分,其他播放逻辑放在后续篇幅讲解。代码属于公司资产,个人无权分享太多,所以只讲重点细节和思路。

1、先贴一下相关bean文件

kotlin 复制代码
//圆角背景
class RoundBackgroundColorSpan(val bgColor: Int, val textColor: Int, val padding: Float, val radius: Float) : ReplacementSpan() {
    override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
        return (paint.measureText(text, start, end) + padding * 2).toInt()
    }

    override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
        val originalColor = paint.color
        paint.color = bgColor
        val width = paint.measureText(text, start, end) + padding * 2
        val height = bottom - top
        canvas.drawRoundRect(x, top.toFloat(), x + width, bottom.toFloat(), radius, radius, paint)
        paint.color = textColor
        canvas.drawText(text!!, start, end, x + padding, y.toFloat(), paint)
        paint.color = originalColor
    }
}

//源数据
class ReadTextBean {
    var text: String? = null
    var details: String? = null
    var select: Boolean = false //当前选中
}
//段落数据
class ParagraphBean {
    var select: Boolean = false //当前选中
    var list: List<ReadTextBean>? = null
    var startIdx: Int = -1 //当前段落开始索引
    var endIdx: Int = -1  //当前段落结束索引
    var fullText: String? = null //当前段落完整文本
    var spans: List<TextSpan>? = null //当前段落文本标签

    data class TextSpan(
        var text: String,      // 文本内容
        var start: Int,        // 起始位置
        var end: Int,          // 结束位置
        var isItalic: Boolean, // 是否为斜体
        var isSpan: Boolean,    // 是否为span标签(需要圆角背景)
    )
}

2、将源数据 拼接段落打上标签 组成列表数据 在vm类中实现 ,其中标签的嵌套和 重复处理比较麻烦 当时也是费了很多心思,逻辑也比较复杂

ini 复制代码
var readList = MutableLiveData<List<ReadTextBean>>() //  阅读列表
var paragraphList = MutableLiveData<List<ParagraphBean>?>() //  段落列表

//获取段落
fun getParagraph() { // 段落
        launch({
            val dataList = readList.value // 数据源列表
            if (dataList.isNullOrEmpty()) {
                paragraphList.value = mutableListOf()
                return@launch
            }
            paragraphList.postValue(null)
            val endIndices = ArrayList<Int>() // 存储段落结束位置索引
            for (i in dataList.indices) {
                val text = dataList[i].text
                if (!text.isNullOrBlank() && text.endsWith("\n")) {
                    endIndices.add(i)
                }
            }
            val result = mutableListOf<ParagraphBean>()
            if (endIndices.isNotEmpty()) {
                var prevEnd = -1
                for (idx in endIndices) {
                    val start = prevEnd + 1
                    if (start <= idx) {
                        val readList = dataList.subList(start, idx + 1)
                        val spanText = getFullText(readList)
                        val showText = spanText.replace(Regex("</?(em|span)[^>]*>"), "")
                        result.add(
                            ParagraphBean().apply {
                                list = readList
                                select = false
                                startIdx = start
                                endIdx = idx
                                fullText = showText
                                spans = getSpans(spanText)
                            }
                        )
                    }
                    prevEnd = idx
                }

                // 处理最后剩余部分(如果有)
                val lastEnd = prevEnd
                val remainingStart = lastEnd + 1
                if (remainingStart < dataList.size) {
                    val readList = dataList.subList(remainingStart, dataList.size)
                    val spanText = getFullText(readList)
                    val showText = spanText.replace(Regex("</?(em|span)[^>]*>"), "")
                    result.add(
                        ParagraphBean().apply {
                            list = readList
                            select = false
                            startIdx = remainingStart
                            endIdx = dataList.size - 1
                            fullText = showText
                            spans = getSpans(spanText)
                        }
                    )
                }
            } else {
                val spanText = getFullText(dataList)
                val showText = spanText.replace(Regex("</?(em|span)[^>]*>"), "")
                result.add(
                    ParagraphBean().apply {
                        list = dataList
                        select = false
                        startIdx = 0
                        endIdx = dataList.size - 1
                        fullText = showText
                        spans = getSpans(spanText)
                    }
                )
            }

            this.paragraphList.postValue(result)
        }, {
//            Log.e(TAG, "getParagraph: $it")

        }, {
            Log.e(TAG, "getParagraph: err${it.message}")
        })



    }
    val BULLET_UNICODE = "\uF0B7" //表示"•"符号
    val NEW_LINE = "\n"
    fun getFullText(list: List<ReadTextBean>?): String {
        var text = StringBuilder().apply {
            if (list != null) {
                for (element in list) {
                    append(element.text)
                }
            }
        }.toString()
        text = text.replace(Regex("[$BULLET_UNICODE$NEW_LINE]"), "").trim() // 去掉换行符
//        println("text : $text")
        return text
    }

//处理标签 其中标签的嵌套和 重复处理比较麻烦
    fun getSpans(str: String): List<ParagraphBean.TextSpan>{
        var text = str
        val spans = mutableListOf<ParagraphBean.TextSpan>()
        if(text.contains("<span>")){ // 包含 <span>
            var matchesEm: List<MatchResult>? = null
            if(text.contains("<em>")){ // 嵌套包含 <em>
                val regex = Regex("<em>(.*?)</em>")
                // 查找所有 <em> 标签内的内容
                matchesEm = regex.findAll(text).toList()
                text = text.replace("<em>", "")
                text = text.replace("</em>", "")
            }
            val regex = Regex("<span>(.*?)</span>")
            // 查找所有 <span> 标签内的内容
            val matchesSpan = regex.findAll(text).toList()
            text = text.replace("<span>", "")
            text = text.replace("</span>", "")
            if (matchesEm != null) {
                for (match in matchesEm) {
//                            println("Em content: ${match.groupValues[1]}")
//                            println("Start index: ${match.range.first}")
//                            println("End index: ${match.range.last}")
                    val emText = match.groupValues[1]
                    val emStart = text.indexOf(emText)
                    val emEnd = emStart + emText.length
                    if (emStart != -1 && emEnd != -1) {
                        spans.add(ParagraphBean.TextSpan(emText, emStart, emEnd, true,  false))
                    }
                }
            }

            for (match in matchesSpan) {
//                        println("Span content: ${match.groupValues[1]}")
//                        println("Start index: ${match.range.first}")
//                        println("End index: ${match.range.last}")
                val spanText = match.groupValues[1]
                val spanStart = text.indexOf(spanText)
                val spanEnd = spanStart + spanText.length
                if (spanStart != -1 && spanEnd != -1) {
//                    wordSpans.add(match.groupValues[1])
                    var isItalic = false // 是否为斜体
                    val iterator = spans.iterator()
                    while (iterator.hasNext()) { // 遍历已添加的 span
                        val span = iterator.next()
                        if (spanText.contains(span.text)) { // 如果包含
                            isItalic = true // 设置为斜体
                            iterator.remove() // 从列表中删除
                            break
                        }
                    }
                    spans.add(ParagraphBean.TextSpan(spanText, spanStart, spanEnd, isItalic,  true))
                }
            }

        }else if(text.contains("<em>")){ // 包含 <em>
            // 定义正则表达式
            val regex = Regex("<em>(.*?)</em>")
            // 查找所有 <em> 标签内的内容
            val matchesEm = regex.findAll(text).toList()
            text = text.replace("<em>", "")
            text = text.replace("</em>", "")
            for (match in matchesEm) {
//                            println("Em content: ${match.groupValues[1]}")
//                            println("Start index: ${match.range.first}")
//                            println("End index: ${match.range.last}")
                val emStart = text.indexOf(match.groupValues[1])
                val emEnd = emStart + match.groupValues[1].length
                if (emStart != -1 && emEnd != -1) {
                    spans.add(ParagraphBean.TextSpan(match.groupValues[1], emStart, emEnd, true,  false))
                }
            }
        }
        return spans
    }

3、activity中的就很简单

javascript 复制代码
mViewModel.paragraphList.observe(this) {
            adapter?.setList(it)
           
        }
        mViewModel.readList.observe(this) {
            if (it.isNotEmpty()) {
                mViewModel.getParagraph() // 获取段落信息
            }
        }

4、adapter中的ui处理

kotlin 复制代码
class ReadTextAdapter(data: MutableList<ParagraphBean>) : BaseQuickAdapter<ParagraphBean, BaseDataBindingHolder<ItemReadTextBinding>>(R.layout.item_read_text, data) {

    private var defSel = -1
    private var wordListener: OnWordListener? = null
    private var itemListener: OnItemListener? = null
    @RequiresApi(Build.VERSION_CODES.N)
    override fun convert(holder: BaseDataBindingHolder<ItemReadTextBinding>, item: ParagraphBean) {
        holder.dataBinding?.let { it ->
            it.bean = item
            val text = item.fullText?: ""
            if(item.spans != null){
                val spannableString = SpannableString(text)
                for(span in item.spans!!){
                    if(span.isItalic){
                        spannableString.setSpan(StyleSpan(Typeface.ITALIC), span.start, span.end, Spanned.SPAN_INCLUSIVE_INCLUSIVE)// 斜体
                    }
                    if(span.isSpan){
                        val color = if (item.select) {
                            context.resources.getColor(R.color.col_00ADEF)
                        } else {
                            context.resources.getColor(R.color.col_333333)
                        }
                        val spanBg = RoundBackgroundColorSpan(
                            bgColor = context.resources.getColor(R.color.col_00ADEF_20), // 背景色
                            textColor = color, // 文字颜色
                            padding = context.resources.getDimensionPixelOffset(com.jxw.jetpackmvvm.R.dimen.x10).toFloat(), // 水平内边距
                            radius = context.resources.getDimensionPixelOffset(com.jxw.jetpackmvvm.R.dimen.x34).toFloat(), // 圆角半径
                        )
                        spannableString.setSpan(spanBg, span.start, span.end, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
                        spannableString.setSpan(object : ClickableSpan() {
                            override fun onClick(widget: View) {
                                wordListener?.click(span.text)
                            }
                            override fun updateDrawState(ds: TextPaint) {
                                // 移除下划线并保持文字颜色
                                ds.isUnderlineText = false
                            }
                        }, span.start, span.end, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
                    }
                }
                it.readText.text = spannableString
                it.readText.movementMethod = LinkMovementMethod.getInstance() // 使链接可点击
                // 3. 处理TextView的点击事件(不会干扰ClickableSpan)
                it.readText.setOnTouchListener { v, event ->
                    val textView = v as TextView
                    val spannable = textView.text as SpannableString
                    if (event.action == MotionEvent.ACTION_UP) {
                        val x = event.x.toInt() - textView.totalPaddingLeft + textView.scrollX
                        val y = event.y.toInt() - textView.totalPaddingTop + textView.scrollY
                        val layout = textView.layout ?: return@setOnTouchListener false
                        val line = layout.getLineForVertical(y)
                        val offset = layout.getOffsetForHorizontal(line, x.toFloat())
                        val spans = spannable.getSpans(offset, offset, ClickableSpan::class.java)
                        if (spans.isEmpty()) {
                            // 没有点击到可点击文本,执行TextView点击事件
                            itemListener?.click(holder.position)
                            return@setOnTouchListener true
                        }
                    }
                    false
                }
            }

        }
    }

    fun setSel(pos: Int) {
        if (data.size <= 0 || pos < 0 || pos >= data.size) {
            return
        }
        if (defSel != -1 && defSel >= 0 && defSel < data.size) {
            data[defSel].select = false
            notifyItemChanged(defSel)
        }
        defSel = pos
        if (defSel != -1) {
            data[defSel].select = true
            notifyItemChanged(defSel)
        }
    }

    interface OnWordListener {
        fun click(text: String)
    }
    fun setWordListener(listener: OnWordListener){
        wordListener = listener
    }

    interface OnItemListener {
        fun click(poi: Int)
    }
    fun setItemListener(listener: OnItemListener){
        itemListener = listener
    }

}

相关推荐
huaCodeA15 小时前
Android面试-Kotlin Coroutines(协程)
android·面试·kotlin
jzlhll12316 小时前
android kotlin Flow:distinctUntilChangedBy + stateIn 的坑
android·开发语言·kotlin
唐青枫18 小时前
Kotlin 高阶函数别只会 map:从 Lambda 到实战封装
kotlin
plainGeekDev19 小时前
Android四大组件面试题,看完这篇就够了
android·面试·kotlin
黄林晴20 小时前
Kotlin 官方发布kotlin-agent-skills,迁移/转换一键规范
android·kotlin
Kapaseker20 小时前
Kotlin 准备引入 [1,2,3] 创建集合
android·kotlin
plainGeekDev1 天前
Glide 该换了?Coil:Kotlin 时代的图片加载库
android·开源·kotlin
plainGeekDev1 天前
Android内存面试题:OOM都解决不了,性能优化从何谈起?
android·面试·kotlin
疏狂难除2 天前
JetBrains IDE插件开发教程(二)——学习初始代码
ide·kotlin