在 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,核心交互点有三处:
- 选中态高亮 :通过
bean.select控制背景色与底部操作栏显隐 - 星级评分 :根据
score动态切换 1-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)需纠正,符合教育场景直觉。
五、音频管理:原音与录音的播放互斥
模块中存在两套音频播放体系:
- 原音播放 :
MediaCommonUtil.playMusic()+JwgtEngine.readCover()(从资源包读取 MP3) - 录音播放 :
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。 - 权限结果通过
hasRecordPermissionLiveData 通知 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
}
}
}
}