Android 英语口语评测:从录音采集到单词级着色反馈的完整技术方案

在 K12 教育类 App 中,"英语跟读评测"几乎是口语板块的标配功能。看似简单的"录音-打分-展示"流程,实际开发中却涉及音频焦点管理 (原音与录音不能同时播放)、第三方评测 SDK 的异步回调句子级到单词级的细粒度可视化反馈 、以及本地评分持久化等多个技术点的耦合。

本文将基于一个真实生产环境的实现,完整拆解从 UI 布局、Adapter 状态管理、ViewModel 评测调度,到 Room 数据库落库的全链路方案。涉及 DataBinding + RecyclerView 实现可交互列表、SpannableString 实现按单词着色、声通评测 SDK 的集成与生命周期管理,以及音频播放互斥的兜底策略。

一、功能全景与架构设计

整个评测模块由四个核心文件协作完成:

文件 职责
item_evaluation.xml DataBinding 布局,定义评测卡片(原文、星级、操作按钮)
EvaluationAdapter.kt RecyclerView Adapter,处理 item 状态、点击事件、文字着色
EvaluateViewModel.kt 业务调度中心,管理评测 SDK、音频播放、数据库读写
EvaluateListFragment.kt UI 容器,负责权限申请、生命周期绑定、数据初始化

架构采用 MVVM + Repository(隐式)

  • ViewModel 持有 readList(句子列表)、isEvaluating(评测状态)、mPos(当前选中索引)等 LiveData
  • Fragment 观察 LiveData,只负责 UI 刷新和权限申请。
  • Adapter 通过接口回调将点击事件上报给 Fragment,再由 Fragment 调用 ViewModel 方法,避免 Adapter 直接操作业务。

二、列表层:DataBinding 实现可交互评测卡片

布局采用 layout 根标签支持 DataBinding,核心交互点有三处:

  1. 选中态高亮 :通过 bean.select 控制背景色与底部操作栏显隐
  2. 星级评分 :根据 score 动态切换 1-3 星图标
  3. 操作按钮:播放原音、点击测评、我的录音(录音存在时才显示)
xml 复制代码
<!-- 选中态背景切换 -->
android:background="@{bean.select?@color/col_F6F6F6:@color/white}"

<!-- 操作栏显隐 -->
android:visibility="@{bean.select?View.VISIBLE:View.GONE}"

Adapter 中的状态防御

  • isEvaluating 字段控制评测中禁止一切音频操作(播放原音、播放录音、切换 item)。
  • hasRecordPermission 在点击"点击测评"时拦截,无权限直接 Toast 提示。

这里有一个设计细节:Adapter 内部持有 needPlay 布尔值,用于实现首次选中自动播放原音 ------当 item.select && needPlay 时触发 llBfyy.performClick(),随后置 false,避免重复播放。

三、评测核心:SDK 集成与录音生命周期

评测引擎采用声通(或类似)的本地评测 SDK,核心类型为 CoreType.EN_SENT_EVAL(句子级评测)。

录音启动流程

ini 复制代码
val recordSetting = RecordSetting(mCoreType, wordText.value).apply {
    recordFilePath = baseUrl      // 音频存储目录
    audioType = "wav"
    recordName = "${poi}test_audio.wav"
    coreProvideType = EngineType.ENGINE_NATIVE
    output_rawtext = 1            // 要求返回原始评测文本
}
EvalUtils.skEgnManager?.startRecord(recordSetting, mOnRecorderListener)

关键回调处理

  • onRecordEnd():录音停止,但此时评测结果可能还未返回。
  • onScore(result):异步返回 JSON 评测结果,这是整个链路最核心的数据入口。

状态同步的坑点 :录音停止和评测结果返回是两个异步事件。代码中在 onRecordEnd()onScore() 都会调用 isEvaluating.postValue(false),确保无论哪条链路先到达,UI 都能解除"评测中"锁定。

四、可视化反馈:基于 Word-Level 评分的 SpannableString 着色

评测 SDK 返回的 JSON 中,details.words 数组包含每个单词的评分和字符类型:

json 复制代码
{
  "words": [
    {"word": "Hello", "charType": 0, "scores": {"overall": 85}},
    {"word": "world", "charType": 0, "scores": {"overall": 45}}
  ]
}

Adapter 中的着色逻辑:

ini 复制代码
// 遍历每个 word,在原文中查找位置并着色
val sp = SpannableStringBuilder(strItem)
var lastPoi = 0

for (i in 0 until wjsono.length()) {
    val wordJson = wjsono.getJSONObject(i)
    val text = wordJson.optString("word")
    val score = wordJson.optJSONObject("scores")?.optInt("overall") ?: 0
    val charType = wordJson.optInt("charType")

    val color = when {
        score >= 80 || (score == 0 && charType == 1) -> R.color.col_accuracy_green
        score >= 60 -> R.color.col_FF9509
        else -> R.color.col_FF4747
    }

    val index = strItem.indexOf(text, lastPoi)
    if (index >= 0) {
        sp.setSpan(
            ForegroundColorSpan(color),
            index,
            index + text.length,
            Spanned.SPAN_INCLUSIVE_INCLUSIVE
        )
        lastPoi = index + text.length
    }
}

设计细节

  • lastPoi 指针避免重复匹配(如原文中有多个相同单词)。
  • score == 0 && charType == 1 的处理:通常表示标点或不可评测字符,默认给绿色避免视觉干扰。
  • 颜色语义:绿色(≥80)优秀、橙色(≥60)及格、红色(<<60)需纠正,符合教育场景直觉。

五、音频管理:原音与录音的播放互斥

模块中存在两套音频播放体系:

  1. 原音播放MediaCommonUtil.playMusic() + JwgtEngine.readCover()(从资源包读取 MP3)
  2. 录音播放AudioPlayerManager2(ExoPlayer 封装)播放本地 WAV

互斥策略

  • 点击"播放原音" → 先 apm.stop(true) 停止录音播放
  • 点击"播放录音" → 先 MediaCommonUtil.stopMedia() 停止原音
  • 点击"点击测评" → 无论当前在播放什么,一律 MediaCommonUtil.stopMedia() 停止,确保录音通道独占

在低端设备上(你公司的设备场景),这种强制停止+释放的策略是必须的,否则极易出现 AudioTrack 占用冲突导致录音失败或播放无声。

六、数据持久化:Room 本地缓存评分

为避免重复评测浪费流量/时间,每次评测结果通过 Room 落库:

ini 复制代码
val bean = TextScoreBean().apply {
    this.tytId = tytId
    this.themeId = themeId
    this.unitName = unitName
    this.position = position
    this.score = overall
    this.userID = UserUtils.UserID
}
AppDataBase.getDatabase().TextScoreDao().insertInfo(newBean)

数据恢复setJsonList() 方法在初始化列表时,先查询本地数据库,构建 position -> score 映射,批量回显到列表中。这样用户重新进入页面时,已评测的句子直接展示历史星级。

七、权限与生命周期:录音权限与页面销毁兜底

权限申请

  • 使用 ActivityResultContracts.RequestPermission() 申请 RECORD_AUDIO
  • 权限结果通过 hasRecordPermission LiveData 通知 Adapter,控制"点击测评"按钮的可用性。

生命周期兜底(极易被忽略但生产环境必备):

kotlin 复制代码
override fun onPause() {
    super.onPause()
    // 暂停/停止所有音频
    MediaCommonUtil.pauseMedia()
    AudioUtils.stopAudio()
    AudioUtils.stopPreviousDrawable()
    apm.stop(true)
    
    // 页面切后台时,如果正在评测,强制停止录音
    if (EvalUtils.isEvalSuccess && mViewModel.isEvaluating.value) {
        EvalUtils.skEgnManager?.stopRecord()
        mViewModel.isEvaluating.value = false
    }
}

override fun onDestroy() {
    super.onDestroy()
    MediaCommonUtil.stopMedia()
    EvalUtils.skEgnManager?.clearActivityListener()
    mViewModel.deleteFolderAsync(mViewModel.baseUrl) // 清理缓存录音
    apm.release()
}

以下是代码部分:

item_evaluation.xml代码

ini 复制代码
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">

    <data>
        <import type="android.view.View" />

        <variable
            name="bean"
            type="com.data.bean.ReadTextBean" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:focusable="true"
        android:orientation="vertical">

        <RelativeLayout
            android:id="@+id/rl_read"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@{bean.select?@color/col_F6F6F6:@color/white}"
            android:paddingLeft="@dimen/x60"
            android:paddingTop="@dimen/x30"
            android:paddingRight="@dimen/x60"
            android:paddingBottom="@dimen/x20">

            <LinearLayout
                android:id="@+id/ll_rating"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_alignParentEnd="true"
                android:layout_marginTop="@dimen/x8"
                android:orientation="horizontal">

                <TextView
                    android:id="@+id/tv_score"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginEnd="@dimen/x40"
                    android:textColor="@color/col_FF5F5F"
                    android:visibility="gone" />

                <ImageView
                    android:id="@+id/star1"
                    android:layout_width="@dimen/x50"
                    android:layout_height="@dimen/x47"
                    android:scaleType="fitXY"
                    android:src="@drawable/icon_xxhs" />

                <ImageView
                    android:id="@+id/star2"
                    android:layout_width="@dimen/x50"
                    android:layout_height="@dimen/x47"
                    android:layout_marginStart="@dimen/x16"
                    android:scaleType="fitXY"
                    android:src="@drawable/icon_xxhs" />

                <ImageView
                    android:id="@+id/star3"
                    android:layout_width="@dimen/x50"
                    android:layout_height="@dimen/x47"
                    android:layout_marginStart="@dimen/x16"
                    android:scaleType="fitXY"
                    android:src="@drawable/icon_xxhs" />
            </LinearLayout>

            <TextView
                android:id="@+id/tv_read_text"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginEnd="@dimen/x5"
                android:layout_toStartOf="@id/ll_rating"
                android:includeFontPadding="false"
                android:text=""
                android:textColor="@color/col_333333"
                android:textSize="@dimen/x40" />

            <LinearLayout
                android:id="@+id/lay_menu"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_below="@id/tv_read_text"
                android:layout_marginTop="@dimen/x24"
                android:gravity="center_vertical"
                android:orientation="horizontal"
                android:visibility="@{bean.select?View.VISIBLE:View.GONE}">

                <LinearLayout
                    android:id="@+id/ll_bfyy"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:gravity="center_horizontal"
                    android:orientation="vertical">

                    <ImageView
                        android:id="@+id/img_yy"
                        android:layout_width="@dimen/x80"
                        android:layout_height="@dimen/x80"
                        android:layout_marginTop="@dimen/x8"
                        android:background="@drawable/anim_play_audio2" />

                    <TextView
                        android:id="@+id/tv_bfyy"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:layout_marginTop="@dimen/x13"
                        android:includeFontPadding="false"
                        android:text="播放原音"
                        android:textColor="@color/col_333333"
                        android:textSize="@dimen/x24" />
                </LinearLayout>

                <LinearLayout
                    android:id="@+id/ll_djcp"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="@dimen/x68"
                    android:gravity="center_horizontal"
                    android:orientation="vertical">

                    <ImageView
                        android:id="@+id/img_cp2"
                        android:layout_width="@dimen/x176"
                        android:layout_height="@dimen/x96"
                        android:background="@drawable/anim_play_audio4" />

                    <TextView
                        android:id="@+id/tv_cp"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:layout_marginTop="@dimen/x5"
                        android:includeFontPadding="false"
                        android:text="点击测评"
                        android:textColor="@color/col_333333"
                        android:textSize="@dimen/x24" />
                </LinearLayout>

                <LinearLayout
                    android:id="@+id/ll_wdly"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="@dimen/x68"
                    android:gravity="center_horizontal"
                    android:orientation="vertical">

                    <ImageView
                        android:id="@+id/img_ly"
                        android:layout_width="@dimen/x80"
                        android:layout_height="@dimen/x80"
                        android:layout_marginTop="@dimen/x8"
                        android:background="@drawable/anim_play_audio3" />

                    <TextView
                        android:id="@+id/tv_wdly"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:layout_marginTop="@dimen/x13"
                        android:includeFontPadding="false"
                        android:text="我的录音"
                        android:textColor="@color/col_333333"
                        android:textSize="@dimen/x24" />
                </LinearLayout>
            </LinearLayout>
        </RelativeLayout>
    </LinearLayout>
</layout>

EvaluationAdapter.kt代码

kotlin 复制代码
class EvaluationAdapter(data: MutableList<ReadTextBean>) : BaseQuickAdapter<ReadTextBean, BaseDataBindingHolder<ItemEvaluationBinding>>(R.layout.item_evaluation, data) {

    private var defSel = -1
    private var evaluatingListener: OnEvaluatingListener? = null
    private var onPlayOriginalListener: OnPlayOriginalListener? = null
    private var onPlayMySoundListener: OnPlayMySoundListener? = null
    private var isEvaluating = false //是否评测中 false 未评测 true 正在评测
    private var needPlay = true;
    private var hasRecordPermission = false // 记录是否有录音权限

    @RequiresApi(Build.VERSION_CODES.N)
    override fun convert(holder: BaseDataBindingHolder<ItemEvaluationBinding>, item: ReadTextBean) {
        holder.dataBinding?.let {
            it.bean = item
            if(item.text?.isEmpty() == true){
                it.rlRead.visibility = android.view.View.GONE
            }else{
                it.rlRead.visibility = android.view.View.VISIBLE
            }
            if(item.score > -1){
                it.llRating.visibility = android.view.View.VISIBLE
                if(item.score > 80){ //3星
                    it.star1.setImageResource(R.drawable.icon_xxcs);
                    it.star2.setImageResource(R.drawable.icon_xxcs);
                    it.star3.setImageResource(R.drawable.icon_xxcs);
                }else if(item.score >= 60){ //2星
                    it.star1.setImageResource(R.drawable.icon_xxcs);
                    it.star2.setImageResource(R.drawable.icon_xxcs);
                    it.star3.setImageResource(R.drawable.icon_xxhs);
                }else if(item.score > 0){ //1星
                    it.star1.setImageResource(R.drawable.icon_xxcs);
                    it.star2.setImageResource(R.drawable.icon_xxhs);
                    it.star3.setImageResource(R.drawable.icon_xxhs);
                }else{
                    it.star1.setImageResource(R.drawable.icon_xxhs);
                    it.star2.setImageResource(R.drawable.icon_xxhs);
                    it.star3.setImageResource(R.drawable.icon_xxhs);
                }
            }else{
                it.llRating.visibility = android.view.View.GONE
            }
            if(item.hasRecording){
                it.llWdly.visibility = android.view.View.VISIBLE
            }else{
                it.llWdly.visibility = android.view.View.GONE
            }
            val details = item.details
            val strItem = item.text
            if (details != null) {
                try {
                    val jsonObject: JSONObject = JSONObject(details)
                    val wjsono: JSONArray = jsonObject.optJSONArray("words")
                    var wordJSONObject: JSONObject
                    var scoreJSONObject: JSONObject
                    val sp = SpannableStringBuilder(strItem)
                    var colorSpan: ForegroundColorSpan?
                    var lastPoi = 0 //上一个字符的位置
                    for (i in 0 until wjsono.length()) {
                        wordJSONObject = wjsono.getJSONObject(i)
                        val charType: Int = wordJSONObject.optInt("charType")
                        val text: String = wordJSONObject.optString("word")
                        var score = 0
                        if (wordJSONObject.has("scores")) {
                            scoreJSONObject = wordJSONObject.optJSONObject("scores")
                            score = scoreJSONObject.optInt("overall")
                        }
                        if (strItem != null) {
                            if (strItem.indexOf(text, lastPoi) >= 0) {
                                colorSpan = if (score >= 80 || (score == 0 && charType == 1)) { //80以上 或者 字符
                                    ForegroundColorSpan(context.resources.getColor(R.color.col_accuracy_green))
                                } else if (score >= 60) {
                                    ForegroundColorSpan(context.resources.getColor(R.color.col_FF9509))
                                } else {
                                    ForegroundColorSpan(context.resources.getColor(R.color.col_FF4747))
                                }
                                sp.setSpan(
                                    colorSpan,
                                    strItem.indexOf(text, lastPoi),
                                    (strItem.indexOf(text, lastPoi) + text.length),
                                    Spanned.SPAN_INCLUSIVE_INCLUSIVE
                                )
                                lastPoi = strItem.indexOf(text, lastPoi) + text.length
                            }
                        }
                    }
                    it.tvReadText.text = sp
                } catch (e: JSONException) {
                    e.printStackTrace()
                }
            }else if(strItem != null){
                it.tvReadText.text = item.text
            }
            it.llBfyy.setOnClickListener {//播放原音
                if(!isEvaluating){ //正在测评中
                    AudioUtils.playAnimDrawable(holder.dataBinding!!.imgYy);
                    onPlayOriginalListener?.OnPlay()
                }
            }
            if(item.select && needPlay){
                it.llBfyy.performClick()
                needPlay = false
            }
            it.llWdly.setOnClickListener {//播放我的声音
                if(!isEvaluating){ //正在测评中
                    MediaCommonUtil.stopMedia()//停止播放原音
                    AudioUtils.playAnimDrawable(holder.dataBinding?.imgLy);
                    onPlayMySoundListener?.OnPlay()
                }
            }
            it.llDjcp.setOnClickListener {
                if(!hasRecordPermission){
                    ToastUtils.showShort("请授予录音权限,否则无法进行评测")
                    return@setOnClickListener
                }
                isEvaluating = !isEvaluating;
                MediaCommonUtil.stopMedia()//停止播放原音
                if(isEvaluating){ //开始录音
                    AudioUtils.playAnimDrawable(holder.dataBinding!!.imgCp2);
                    holder.dataBinding!!.tvCp.text = "点击停止";
                    StudyDetailsUtil.insertClick(appViewModel.unitName.value, "口语评测")
                }else {
                    AudioUtils.stopPreviousDrawable();
                    holder.dataBinding!!.tvCp.text = "点击测评";
                }
                evaluatingListener?.OnEvaluating(isEvaluating)
            }
        }
    }

    fun setOnEvaluatingListener(listener: OnEvaluatingListener) {
        this.evaluatingListener = listener
    }

    fun setOnPlayOriginalListener(listener: OnPlayOriginalListener) {
        this.onPlayOriginalListener = listener
    }

    fun setOnPlayMySoundListener(listener: OnPlayMySoundListener) {
        this.onPlayMySoundListener = listener
    }

    interface OnEvaluatingListener {
        fun OnEvaluating(eva: Boolean)
    }

    interface OnPlayOriginalListener {
        fun OnPlay()
    }

    interface OnPlayMySoundListener {
        fun OnPlay()
    }

    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
        needPlay = true
        if (defSel != -1) {
            data[defSel].select = true
            notifyItemChanged(defSel)
        }
    }

    fun setRecordPermission(hasRecordPermission: Boolean) {
        this.hasRecordPermission = hasRecordPermission
    }

}

EvaluateViewModel.kt代码

kotlin 复制代码
class EvaluateViewModel : BaseViewModel() {

    companion object {
        private const val TAG = "EvaluateViewModel"
    }

    var mPos = IntLiveData() //当前位置
    var wordText = StringLiveData() //当前文本
    var readList = MutableLiveData<List<ReadTextBean>>() //  阅读列表
    var isEvaluating = BooleanLiveData() // 是否正在评测
    var hasRecordPermission = BooleanLiveData() // 记录是否有录音权限

    var baseUrl: String = App.instance.cacheDir.absolutePath + "/sjyyb_cp"
    var savePath: String? = null //当前音频文件地址
    var cplyList: List<CpLyUrlBean> = mutableListOf()//我的录音列表

    private fun saveScore(score: Int) { //保存分数 每句评分
        val tytId = appViewModel.tytId.value
        val unitName = appViewModel.unitName.value
        val themeId = appViewModel.themeId.value
        val position = mPos.value
        if (tytId.isBlank() || unitName.isBlank()) {
            return
        }
        launch({
            val bean: TextScoreBean? = AppDataBase.getDatabase().TextScoreDao()
                .getScoreByIndex(themeId, unitName, tytId, position, UserUtils.UserID)
            if (bean != null) {
                // 更新
                bean.score = score
                AppDataBase.getDatabase().TextScoreDao().updateInfo(bean)
            } else {
                // 插入
                val newBean = TextScoreBean().apply {
                    this.tytId = tytId
                    this.themeId = themeId
                    this.unitName = unitName
                    this.position = position
                    this.score = score
                    this.userID = UserUtils.UserID
                }
                AppDataBase.getDatabase().TextScoreDao().insertInfo(newBean)
            }
        }, {

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

        })

    }

    fun playVoice(index: Int) {//播放
        val item = readList.value?.get(index) ?: return
        mPos.value = index
        wordText.value = item.text.toString()
        if (item.mp3 == null) {
            return
        }
        val bytes = JwgtEngine.getInstance().readCover(item.mp3, 0)
        if (bytes != null) {
            val path = EngineUtil.byteToMp3Path(bytes, item.mp3!!)
            if (path.isNullOrEmpty()) {
                return
            }
//            mViewModel.isPlay.value = true
            MediaCommonUtil.playMusic(path, mediaCallBack, 1.0f, true, 0)
        }
    }

    private val mediaCallBack = object : MediaCommonUtil.Callback {//播放监听
        override fun finish() {
            //播放完成后
            AudioUtils.stopPreviousDrawable();
        }

        override fun setTotal(total: Int) { }

        override fun setProgress(progress: Int) { }
    }

    fun start() {
        if (!EvalUtils.isEvalSuccess) {
            ToastUtils.showShort("测评初始化中...")
            return
        }
        if (!AppUtil.validateMicAvailability()) {
            ToastUtils.showShort("不能录音,无法正常使用")
            return
        }
        Log.i("zcc", "word-- ${wordText.value}")
        try {
            val mCoreType = CoreType.EN_SENT_EVAL //句子
            val poi: String = (mPos.value + 1).toString() + ""
            savePath = baseUrl + "/" + poi + "test_audio.wav"
            Log.i("zcc", "savePath-- $savePath")
            val recordSetting: RecordSetting = RecordSetting(mCoreType, wordText.value)
            recordSetting.recordFilePath = baseUrl //录音 音频文件地址
            recordSetting.audioType = "wav"
            recordSetting.recordName = poi + "test_audio.wav" //音频文件名
            recordSetting.coreProvideType = EngineType.ENGINE_NATIVE
            recordSetting.output_rawtext = 1 //返回原始评测文本
            EvalUtils.skEgnManager?.startRecord(recordSetting, mOnRecorderListener)
        } catch (e: Exception) {
            Log.e("zcc", "error = " + e.message)
            e.printStackTrace()
        }
    }

    var mOnRecorderListener: OnRecorderListener = object : OnRecorderListener() {
        //声通评测监听
        override fun onStart() {
            //开始录制
        }

        override fun onStartRecordFail(var1: String) {
            //录制失败
            onStopSending()
            onError(var1)
        }

        override fun onPause() {
            //暂停录音
            onStopSending()
        }

        override fun onTick(var1: Long, var3: Double) {
            //倒计时回调,var1:剩余时间(ms), var2:百分比
        }

        override fun onRecordEnd() {
            //录制结束
            Log.e("zcc", "RecordEnd")
            onStopSending()
        }


        override fun onRecording(vad_status: Int, sound_intensity: Int) {
            //录制中
//            Log.e("zcc", "音强===>" + sound_intensity);
        }

        override fun onScore(result: String) { //结果
            try {
                //评测返回结果回调
                val str = result
                if (str.length > 2000) {
                    var i = 0
                    while (i < str.length) {
                        //当前截取的长度<总长度则继续截取最大的长度来打印
                        if (i + 2000 < str.length) {
                            Log.i("zcc$i", str.substring(i, i + 2000))
                        } else {
                            //当前截取的长度已经超过了总长度,则打印出剩下的全部信息
                            Log.i("zcc$i", str.substring(i, str.length))
                        }
                        i += 2000
                    }
                } else {
                    //直接打印
                    Log.i("zcc", "result:$result")
                }
                if (!EvalUtils.isEvalSuccess) {
                    return
                }
                val returnObj: JSONObject = JSONObject(result)
                val resultJSONObject: JSONObject = returnObj.getJSONObject("result")
                var overall = 0
                if (resultJSONObject.has("overall")) {
                    overall = resultJSONObject.getString("overall").toInt()
                }
                val details: String = resultJSONObject.toString()
                Log.e("zcc", "overall : $overall")
                saveScore(overall) //保存评分
                Handler(Looper.getMainLooper()).post {
                    // 主线程操作
                    readList.value?.get(mPos.value)?.details = details
                    readList.value?.get(mPos.value)?.hasRecording = true
                    readList.value?.get(mPos.value)?.score = overall
                    if (cplyList.isNotEmpty()) {
                        cplyList[mPos.value].pathUrl = savePath
                        Log.e("zcc", "savePath : $savePath")
                    }
                    isEvaluating.value = false
                }
            } catch (e: JSONException) {
                // TODO Auto-generated catch block
                e.printStackTrace()
            } catch (e: JSONException) {
                e.printStackTrace()
            }
        }
    }

    private fun onStopSending() {
        //录音停止时回调
        Log.d(TAG,"当录音停止并写入成功后回调")
        isEvaluating.postValue(false)
    }

    private fun onError(msg: String) {
        //评测错误回调
        Log.e(TAG, "onError msg= $msg")
        isEvaluating.postValue(false)
    }

    fun deleteFolderAsync(path: String){
        launch({
            File(path).deleteRecursively()
        }, {

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

        })
    }
}

EvaluateListFragment.kt代码

kotlin 复制代码
/**
 * 评测列表
 **/
class EvaluateListFragment : BaseFragment<EvaluateViewModel, LayoutFragmentListBinding>() {
    companion object {
        private const val TAG = "EvaluateListFragment"

        fun getInstance(): EvaluateListFragment {
            val fragment = EvaluateListFragment()
            val bundle = Bundle()
            fragment.arguments = bundle
            return fragment
        }
    }

    override fun layoutId(): Int {
        return R.layout.layout_fragment_list
    }
    var adapter:  EvaluationAdapter? = null
    var apm = AudioPlayerManager2.getInstance(App.instance);
    override fun initView(savedInstanceState: Bundle?) {
        apm.switchSpeed(1f)
        apm.setlistener( object : Player.Listener {
            override fun onPlaybackStateChanged(playbackState: Int) {
                if (playbackState == Player.STATE_ENDED) {
                    Log.e(TAG, "onPlaybackStateChanged: 播放完成")
                    AudioUtils.stopPreviousDrawable()
                }
            }
        })
        adapter = EvaluationAdapter(mutableListOf()).apply {
            setOnItemClickListener { _, _, position ->
                if(mViewModel.isEvaluating.value){//评测中
                    return@setOnItemClickListener
                }
                adapter?.setSel(position)
                mViewModel.mPos.value = position
            }
        }
        adapter?.setOnPlayOriginalListener(object : EvaluationAdapter.OnPlayOriginalListener {
            override fun OnPlay() {//播放原声
                apm.stop(true)
                mViewModel.playVoice(mViewModel.mPos.value)
            }
        })
        adapter?.setOnPlayMySoundListener(object : EvaluationAdapter.OnPlayMySoundListener {
            override fun OnPlay() {//播放录音
                if (mViewModel.cplyList.isNotEmpty() && mViewModel.cplyList[mViewModel.mPos.value].pathUrl!=null) {
//                    Log.e("zcc", "Url= "+cplyList.get(mPosSomeTime).getPathUrl());
                    val path = mViewModel.cplyList[mViewModel.mPos.value].pathUrl
                    apm.setAudioFile2(path) //播放录音
                    apm.start()
                } else {
                    Log.e(TAG, "录音列表为空 ")
                }
            }
        })
        adapter?.setOnEvaluatingListener(object : EvaluationAdapter.OnEvaluatingListener {
            override fun OnEvaluating(eva: Boolean) { //评测
                if (EvalUtils.isEvalSuccess) {
                    mViewModel.isEvaluating.value = eva;
                    if (mViewModel.isEvaluating.value) {
                        Log.e(TAG, "开始评测=== ")
                        apm.stop(true);
                        mViewModel.start()
                    } else {
                        Log.e(TAG, "停止录音=== ")
                        //手动停止录音,等待评测
                        EvalUtils.skEgnManager?.stopRecord();

                    }
                } else {
                    ToastUtils.showShort("测评初始化中...");
                    if(!EvalUtils.isEvalSuccess) {
                        EvalUtils.initSpeechEval(context);
                    }
                }

            }
        })
        mDatabind.recyclerView.layoutManager = LinearLayoutManager(context).apply {
            orientation = LinearLayoutManager.VERTICAL
        }
        mDatabind.recyclerView.adapter = adapter
        checkAndRequestRecordPermission()
    }


    override fun createObserver() {
        super.createObserver()
        mViewModel.readList.observe(this) {
            if (it.isNotEmpty()) {
                adapter?.setList(it)
                isFirst = true
            }
        }
        mViewModel.isEvaluating.observe(this) {
            if (!it) {
                adapter?.notifyItemChanged(mViewModel.mPos.value)
            }
        }
        mViewModel.hasRecordPermission.observe(this) {
            adapter?.setRecordPermission(it)
        }
    }

    fun setJsonList(json: String, themeId: Int, unitName: String, tytId: String) {
        try {
            val readTextArray = Gson().fromJson(json, Array<ReadTextBean>::class.java)
            val readTextList = readTextArray?.toList()?.toMutableList() ?: mutableListOf()
            if (readTextList.isNotEmpty()) {
                val textScoreList = AppDataBase.getDatabase().TextScoreDao()
                    .getScoreByUnit(themeId, unitName, tytId, UserUtils.UserID)
                if (textScoreList.isNotEmpty()) {
                    // 构建 position -> score 映射,提高查找效率
                    val scoreMap = textScoreList.associate { it.position to it.score }
                    for (position in scoreMap.keys) {
                        if (position in readTextList.indices) {
                            readTextList[position].score = scoreMap[position] ?: 0
                        }
                    }
                }
                val list = mutableListOf<CpLyUrlBean>()
                for (i in 0 until readTextList.size) {
                    readTextList[i].text = filterEnglishText(readTextList[i].text ?: "")
                    val b = CpLyUrlBean()
                    b.index = i
                    list.add(b)
                }
                mViewModel.cplyList = list
                Log.e("zcc", "cplyList.size : ${mViewModel.cplyList.size}")
                mViewModel.readList.value = readTextList
            }
        } catch (e: Exception){
            e.printStackTrace()
        }
    }

    var isFirst = true
    override fun onResume() {
        super.onResume()
        if(isFirst){
            adapter?.setSel(0)
        }
        isFirst = false
    }

    override fun onPause() {
        super.onPause()
        MediaCommonUtil.pauseMedia()
        AudioUtils.stopAudio();
        AudioUtils.stopPreviousDrawable()
        apm.stop(true)
        if (EvalUtils.isEvalSuccess) {
            if (mViewModel.isEvaluating.value) {
                mViewModel.isEvaluating.value = false
                Log.e(TAG, "停止录音=== ")
                //手动停止录音,等待评测
                EvalUtils.skEgnManager?.stopRecord();
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        MediaCommonUtil.stopMedia()
        EvalUtils.skEgnManager?.clearActivityListener();
        mViewModel.deleteFolderAsync(mViewModel.baseUrl)
        apm.release()
    }

    fun filterEnglishText(input: String): String {
        val regex = """[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef]|[a-zA-Z]+(?=[\u4e00-\u9fa5])""".toRegex()
        return input
            .replace(Regex("<[^>]*>"), "")                  // 移除HTML标签
            .replace(Regex("《[^《》]*》"), "")              // 移除《》及其内容
            .replace(regex, "") // 移除中文及中文标点以及中文段包含的英文
            .replace(Regex("["()""---...\n]"), "")             // 移除非英文标点
            .trim()

    }


    // 定义权限请求
    private val requestPermissionLauncher = registerForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { isGranted ->
        if (isGranted) {
            // 权限已授予
            Log.e(TAG, "录音权限已授予")
            mViewModel.hasRecordPermission.value = true
        } else {
            // 权限被拒绝
            mViewModel.hasRecordPermission.value = false
            Log.e(TAG, "录音权限被拒绝")
            ToastUtils.showShort("请授予录音权限,否则无法进行评测")
        }
    }
    // 检查并请求权限
    fun checkAndRequestRecordPermission() {
        when {
            ContextCompat.checkSelfPermission(requireActivity(),Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED -> {
                // 已有权限
                Log.e(TAG, "已有录音权限")
                mViewModel.hasRecordPermission.value = true
            }
            ActivityCompat.shouldShowRequestPermissionRationale(requireActivity(), Manifest.permission.RECORD_AUDIO) -> {
                // 解释为什么需要权限
                requestPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
                mViewModel.hasRecordPermission.value = false
            }
            else -> {
                // 直接请求权限
                requestPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
                mViewModel.hasRecordPermission.value = false
            }
        }
    }
}

相关推荐
plainGeekDev5 小时前
文件读写(Java IO)→ Kotlin 扩展函数
android·java·kotlin
消失的旧时光-19438 小时前
Kotlin 协程设计思想(九):Flow 到底是什么?为什么 suspend 函数还需要 Flow?
android·kotlin·协程·协程异常
消失的旧时光-19438 小时前
Kotlin 协程设计思想(八):suspend 到底是什么?为什么 suspend 不是开启协程?
android·kotlin·suspend·continuation
plainGeekDev8 小时前
SharedPreferences → DataStore
android·java·kotlin
plainGeekDev8 小时前
Cursor 操作 → Room DAO
android·java·kotlin
朝星9 小时前
Android开发[10]:性能优化之内存
android·kotlin
brycegao32110 小时前
Android MVI进阶:纯原生实现Slot化可插拔架构
android·kotlin·架构设计·mvi·viewmodel
Kapaseker10 小时前
你遇到过 Kotlin 协程中的竞争问题吗?
android·kotlin