语音输入
前言
在上一篇文章中完成了手动输入进行物品搜索分类,本文将进行语音搜索分类。
正文
上一篇文章中是在主页面中进行手动输入的,而如果我想要在这里面在加上语音搜索的入口自然也是也可以的,只不过页面的代码就很多了,因此痛定思痛我打算新写一个页面单独做这个语音输入,然后搜索物品分类,这样可能看得人也好理解的,因此本文中可能会先改动这个MainActivity,然后把手动输入搜索移到一个新的页面中。这样主页面就作为其他的方式的入口。
一、新增文字输入页面
首先在com.llw.trash下新建一个ui包,后面我们创建的所有Activity都放在这个包下,下面在这个包下创建一个名为TextInputActivity的Activity,对应的xml是activity_text_input.xml。里面的布局代码如下:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true" android:orientation="vertical"> <!--标题--> <com.google.android.material.appbar.MaterialToolbar android:id="@+id/toolbar" android:layout_width="match_parent" app:navigationIcon="@mipmap/icon_back" android:layout_height="?attr/actionBarSize" android:background="@color/white"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" android:text="手动输入" android:textColor="@color/black" android:textSize="18sp" /> </com.google.android.material.appbar.MaterialToolbar> <!--搜索物品布局--> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@color/white"> <RelativeLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginStart="@dimen/sp_16" android:layout_marginTop="@dimen/dp_12" android:layout_marginEnd="@dimen/dp_16" android:layout_marginBottom="@dimen/dp_12" android:background="@drawable/search_et_bg" android:orientation="horizontal"> <ImageView android:id="@+id/iv_search" android:layout_width="@dimen/dp_16" android:layout_height="@dimen/dp_16" android:layout_centerVertical="true" android:layout_marginStart="@dimen/dp_12" android:layout_marginEnd="@dimen/dp_8" app:srcCompat="@mipmap/icon_search" /> <EditText android:id="@+id/et_goods" android:layout_width="wrap_content" android:layout_height="match_parent" android:layout_toStartOf="@+id/iv_clear" android:layout_toEndOf="@+id/iv_search" android:background="@null" android:hint="请输入物品" android:imeOptions="actionSearch" android:singleLine="true" android:textColor="@color/black" android:textColorHint="@color/hint_color" android:textCursorDrawable="@drawable/cursor_style" android:textSize="@dimen/sp_14" /> <ImageView android:id="@+id/iv_clear" android:layout_width="@dimen/dp_36" android:layout_height="@dimen/dp_36" android:layout_alignParentEnd="true" android:padding="@dimen/dp_10" android:src="@mipmap/icon_clear" android:visibility="invisible" /> </RelativeLayout> </LinearLayout> <!--列表--> <androidx.recyclerview.widget.RecyclerView android:id="@+id/rv_result" android:layout_width="match_parent" android:layout_height="match_parent" android:overScrollMode="never" android:scrollbars="none" /> </LinearLayout>
这里不同于之前的主页面了,我把标题栏背景改成了白色,字体改成了黑色,而Toolbar中设置了一个图标用于页面的返回,如下所示。
下面要做的就是把之前写在MainActivity中的代码移动到TextInputActivity中去,这个地方要注意一些地方,首先就是页面的订阅管理,最好是一个页面对应一个订阅管理,之前创建过MainContract,对应的是MainActivity页面,那么TextInputActivity页面,也需要创建一个TextContract类,下面在contract包下创建这个类,代码如下:
package com.llw.goodtrash.contract; import android.annotation.SuppressLint; import com.llw.goodtrash.api.ApiService; import com.llw.goodtrash.model.TrashResponse; import com.llw.mvplibrary.base.BasePresenter; import com.llw.mvplibrary.base.BaseView; import com.llw.mvplibrary.network.NetworkApi; import com.llw.mvplibrary.network.observer.BaseObserver; /** * 手动文字输入页面访问网络 */ public class TextContract { public static class TextPresenter extends BasePresenter<TextView> { /** * 搜索物品 * * @param word 物品名 */ @SuppressLint("CheckResult") public void searchGoods(String word) { ApiService service = NetworkApi.createService(ApiService.class); service.searchGoods(word).compose(NetworkApi.applySchedulers(new BaseObserver<TrashResponse>() { @Override public void onSuccess(TrashResponse groupResponse) { if (getView() != null) { getView().getSearchResponse(groupResponse); } } @Override public void onFailure(Throwable e) { if (getView() != null) { getView().getSearchResponseFailed(e); } } })); } } public interface TextView extends BaseView { /** * 搜索物品返回 * * @param response */ void getSearchResponse(TrashResponse response); /** * 搜索物品异常返回 * * @param throwable */ void getSearchResponseFailed(Throwable throwable); } }
可以看到这个代码和MainContract如出一辙,只是有些地方名称不同,那么下面就可以来修改当前的TextInputActivity了,修改后代码如下:
package com.llw.goodtrash.ui; import android.content.Context; import android.os.Bundle; import android.text.Editable; import android.text.TextWatcher; import android.util.Log; import android.view.View; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputMethodManager; import android.widget.EditText; import android.widget.ImageView; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.llw.goodtrash.R; import com.llw.goodtrash.adapter.SearchGoodsAdapter; import com.llw.goodtrash.contract.TextContract; import com.llw.goodtrash.model.TrashResponse; import com.llw.goodtrash.utils.Constant; import com.llw.mvplibrary.mvp.MvpActivity; import java.util.ArrayList; import java.util.List; /** * 手动输入物品进行垃圾分类 * * @author llw * @date 2021/3/31 17:58 */ public class TextInputActivity extends MvpActivity<TextContract.TextPresenter> implements TextContract.TextView { private static final String TAG = "TextInputActivity"; private EditText etGoods;//输入框 private ImageView ivClear;//清空输入框 private RecyclerView rvResult;//结果显示列表 private List<TrashResponse.NewslistBean> newslistBeanList = new ArrayList<>();//数据列表 private SearchGoodsAdapter searchGoodsAdapter;//结果列表适配器 @Override public void initData(Bundle savedInstanceState) { initView(); } @Override public int getLayoutId() { return R.layout.activity_text_input; } @Override protected TextContract.TextPresenter createPresenter() { return new TextContract.TextPresenter(); } /** * 页面初始化 */ private void initView() { etGoods = findViewById(R.id.et_goods); ivClear = findViewById(R.id.iv_clear); rvResult = findViewById(R.id.rv_result); //配置适配器 设置布局和数据源 searchGoodsAdapter = new SearchGoodsAdapter(R.layout.item_search_rv, newslistBeanList); //设置列表的布局管理器 rvResult.setLayoutManager(new LinearLayoutManager(this)); //列表item点击事件 searchGoodsAdapter.setOnItemChildClickListener((adapter, view, position) -> { showMsg("点击了" + newslistBeanList.get(position).getName()); }); //设置列表适配器 rvResult.setAdapter(searchGoodsAdapter); //设置输入监听 etGoods.addTextChangedListener(new TextWatcher() { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } @Override public void onTextChanged(CharSequence s, int start, int before, int count) { if (s.toString().length() < 1) { ivClear.setVisibility(View.INVISIBLE); } else { ivClear.setVisibility(View.VISIBLE); } } @Override public void afterTextChanged(Editable s) { } }); //设置动作监听 etGoods.setOnEditorActionListener((v, actionId, event) -> { if (actionId == EditorInfo.IME_ACTION_SEARCH) { String word = etGoods.getText().toString().trim(); if (word.isEmpty()) { showMsg("请输入物品名"); } else { //显示加载弹窗 showLoadingDialog(); //控制输入法 controlInputMethod(); //请求接口 mPresenter.searchGoods(word); } return true; } return false; }); //清空输入框内容 ivClear.setOnClickListener(v -> { controlInputMethod(); etGoods.setText(""); }); } /** * 控制输入法 * 当输入法打开时关闭,关闭时弹出 */ private void controlInputMethod() { InputMethodManager inputMethodManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); inputMethodManager.toggleSoftInput(0, InputMethodManager.HIDE_NOT_ALWAYS); } /** * 搜索物品返回数据 * * @param response */ @Override public void getSearchResponse(TrashResponse response) { //隐藏加载弹窗 hideLoadingDialog(); if (response.getCode() == Constant.SUCCESS_CODE) { //请求成功 进行数据的渲染 if (response.getNewslist() != null && response.getNewslist().size() > 0) { newslistBeanList.clear(); newslistBeanList.addAll(response.getNewslist()); //刷新适配器 searchGoodsAdapter.notifyDataSetChanged(); } else { showMsg("触及到了知识盲区"); } } else { //显示请求接口失败的原因 showMsg(response.getMsg()); } } /** * 搜索物品失败返回 * * @param throwable 异常信息 */ @Override public void getSearchResponseFailed(Throwable throwable) { hideLoadingDialog(); Log.e(TAG, throwable.toString()); } }
到目前为止都没有什么问题,如果有请告诉我问题出在哪里。下面就该从MainActiivty中进入TextInputActivity了,首先增加两个图标,由于都是白色的,所以我贴出来你也看不到,就到的源码里面去找好了。修改activity_main.xml如下所示:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true" android:orientation="vertical" tools:context=".MainActivity"> <!--标题--> <com.google.android.material.appbar.MaterialToolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:background="@color/colorPrimaryDark"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" android:text="垃圾分类" android:textColor="@color/white" android:textSize="18sp" /> </com.google.android.material.appbar.MaterialToolbar> <!--文字输入--> <com.google.android.material.button.MaterialButton style="@style/Widget.MaterialComponents.Button.UnelevatedButton" android:layout_width="match_parent" android:layout_height="@dimen/dp_60" android:layout_margin="@dimen/dp_16" android:gravity="center" android:insetTop="@dimen/dp_0" android:insetBottom="@dimen/dp_0" android:onClick="jumpTextInput" android:text="文字输入" android:textSize="@dimen/sp_16" android:theme="@style/Theme.MaterialComponents.Light.DarkActionBar" app:backgroundTint="@color/colorPrimaryDark" app:cornerRadius="@dimen/dp_12" app:icon="@mipmap/icon_text_input" app:iconGravity="textStart" app:iconSize="@dimen/dp_24" /> <!--声音输入--> <com.google.android.material.button.MaterialButton style="@style/Widget.MaterialComponents.Button.UnelevatedButton" android:layout_width="match_parent" android:layout_height="@dimen/dp_60" android:layout_margin="@dimen/dp_16" android:gravity="center" android:insetTop="@dimen/dp_0" android:insetBottom="@dimen/dp_0" android:onClick="jumpVoiceInput" android:text="声音输入" android:textSize="@dimen/sp_16" android:theme="@style/Theme.MaterialComponents.Light.DarkActionBar" app:backgroundTint="@color/colorPrimaryDark" app:cornerRadius="@dimen/dp_12" app:icon="@mipmap/icon_voice_input" app:iconGravity="textStart" app:iconSize="@dimen/dp_24" /> </LinearLayout>
这里放了两个按钮,但是两个按钮的onClick都有地方报错,因此我们需要到MainActivity中新增两个方法。在新增之前先把MainActivity清理一下:
package com.llw.goodtrash; import android.content.Intent; import android.os.Bundle; import android.view.View; import com.llw.goodtrash.ui.TextInputActivity; import com.llw.mvplibrary.base.BaseActivity; public class MainActivity extends BaseActivity { @Override public void initData(Bundle savedInstanceState) { } @Override public int getLayoutId() { return R.layout.activity_main; } }
清理的相当干净,现在的Activity只继承了BaseActivity,然后就只需要重写initData和getLayoutId即可。
下面新增两个方法,如下:
/** * 进入文字输入页面 */ public void jumpTextInput(View view) { } /** * 进入声音输入页面 */ public void jumpVoiceInput(View view) { }
由于两个方法的目的地都是跳转到其他的Activity,那么这里可以重写一个方法,区别就是目标Activity不同,那么Activity又是什么呢?是class。因此可以写一个这样的方法。
/** * 进入Activity * @param clazz 目标Activity */ private void gotoActivity(Class<?> clazz) { startActivity(new Intent(MainActivity.this, clazz)); }
这里的当前所在Activity为MainActivity,你也可以用this或者context代替,context是父类BaseActivity中的,子类继承父类可以使用父类中的成员变量。clazz则代表Class,因为class是关键字,因此通常使用clazz或者aClass来表示变量名。至于Class<?>,表示类的通配符,和泛型有相似的地方,也有区别。下面修改jumpTextInput方法
public void jumpTextInput(View view) { gotoActivity(TextInputActivity.class); }
我相信你能看懂,直接运行一下:
可以看到大致没有什么问题,但是TextInputActivity的状态栏还是之前的那样,这样的用户体验是不好的,因此需要做修改,修改状态栏的背景色,然后是图标改成深色。那么为了方便使用,我打算写在BaseActivity中,这样其他的Activity只要继承了BaseActivity就可以直接调用了的。那么首先打开mvplibrary模块,在里面的utils包下新增一个StatusBarUtil类,位置如下图所示:
代码如下:
package com.llw.mvplibrary.network.utils; import android.annotation.TargetApi; import android.app.Activity; import android.graphics.Color; import android.os.Build; import android.view.View; import android.view.Window; import android.view.WindowManager; import androidx.appcompat.app.AppCompatActivity; import java.lang.reflect.Field; import java.lang.reflect.Method; /** * 状态栏工具类 */ public class StatusBarUtil { /** * 修改状态栏为全透明 * * @param activity */ @TargetApi(19) public static void transparencyBar(Activity activity) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { Window window = activity.getWindow(); window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); window.getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); window.setStatusBarColor(Color.TRANSPARENT); } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { Window window = activity.getWindow(); window.setFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS, WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); } } /** * 状态栏亮色模式,设置状态栏黑色文字、图标, * 适配4.4以上版本MIUIV、Flyme和6.0以上版本其他Android * * @param activity * @return 1:MIUUI 2:Flyme 3:android6.0 */ public static int StatusBarLightMode(Activity activity) { int result = 0; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { if (MIUISetStatusBarLightMode(activity, true)) { result = 1; } else if (FlymeSetStatusBarLightMode(activity.getWindow(), true)) { result = 2; } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { activity.getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); result = 3; } } return result; } /** * 修改状态栏颜色,支持4.4以上版本 * * @param activity * @param colorId */ public static void setStatusBarColor(AppCompatActivity activity, int colorId) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { Window window = activity.getWindow(); window.setStatusBarColor(activity.getResources().getColor(colorId)); } } /** * 已知系统类型时,设置状态栏黑色文字、图标。 * 适配4.4以上版本MIUIV、Flyme和6.0以上版本其他Android * * @param activity * @param type 1:MIUUI 2:Flyme 3:android6.0 */ public static void StatusBarLightMode(Activity activity, int type) { if (type == 1) { MIUISetStatusBarLightMode(activity, true); } else if (type == 2) { FlymeSetStatusBarLightMode(activity.getWindow(), true); } else if (type == 3) { activity.getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); } } /** * 状态栏暗色模式,清除MIUI、flyme或6.0以上版本状态栏黑色文字、图标 */ public static void StatusBarDarkMode(Activity activity, int type) { if (type == 1) { MIUISetStatusBarLightMode(activity, false); } else if (type == 2) { FlymeSetStatusBarLightMode(activity.getWindow(), false); } else if (type == 3) { activity.getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_VISIBLE); } } /** * 设置状态栏图标为深色和魅族特定的文字风格 * 可以用来判断是否为Flyme用户 * * @param window 需要设置的窗口 * @param dark 是否把状态栏文字及图标颜色设置为深色 * @return boolean 成功执行返回true */ public static boolean FlymeSetStatusBarLightMode(Window window, boolean dark) { boolean result = false; if (window != null) { try { WindowManager.LayoutParams lp = window.getAttributes(); Field darkFlag = WindowManager.LayoutParams.class .getDeclaredField("MEIZU_FLAG_DARK_STATUS_BAR_ICON"); Field meizuFlags = WindowManager.LayoutParams.class .getDeclaredField("meizuFlags"); darkFlag.setAccessible(true); meizuFlags.setAccessible(true); int bit = darkFlag.getInt(null); int value = meizuFlags.getInt(lp); if (dark) { value |= bit; } else { value &= ~bit; } meizuFlags.setInt(lp, value); window.setAttributes(lp); result = true; } catch (Exception e) { } } return result; } /** * 需要MIUIV6以上 * * @param activity * @param dark 是否把状态栏文字及图标颜色设置为深色 * @return boolean 成功执行返回true */ public static boolean MIUISetStatusBarLightMode(Activity activity, boolean dark) { boolean result = false; Window window = activity.getWindow(); if (window != null) { Class clazz = window.getClass(); try { int darkModeFlag = 0; Class layoutParams = Class.forName("android.view.MiuiWindowManager$LayoutParams"); Field field = layoutParams.getField("EXTRA_FLAG_STATUS_BAR_DARK_MODE"); darkModeFlag = field.getInt(layoutParams); Method extraFlagField = clazz.getMethod("setExtraFlags", int.class, int.class); if (dark) { extraFlagField.invoke(window, darkModeFlag, darkModeFlag);//状态栏透明且黑色字体 } else { extraFlagField.invoke(window, 0, darkModeFlag);//清除黑色字体 } result = true; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { //开发版 7.7.13 及以后版本采用了系统API,旧方法无效但不会报错,所以两个方式都要加上 if (dark) { activity.getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); } else { activity.getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_VISIBLE); } } } catch (Exception e) { } } return result; } }
这样的代码网络上一大把,都是类似的。下面进入BaseActivity,新增如下方法。
/** * 设置页面状态栏 * @param activity 目标Activity * @param bgColorId 状态栏背景颜色 * @param isDarkTheme 状态栏主题 深色则为黑色图标和文字,浅色则为白色图标和文字 */ protected void setStatubar(AppCompatActivity activity,int bgColorId,boolean isDarkTheme){ StatusBarUtil.setStatusBarColor(activity,bgColorId); if(isDarkTheme){ //深色 StatusBarUtil.StatusBarLightMode(this); } }
通过这个方法就可以设置状态栏的背景颜色,图标主题样式。
修改还没有完,因为我们的页面toolbar如果需要返回的话,每一个页面去写返回方法明显不方便,因此也可以写在BaseActivity中,方法如下:
/** * 页面返回 * * @param toolbar 标题 * @param isDestroy 是否销毁 */ protected void back(Toolbar toolbar, boolean isDestroy) { toolbar.setNavigationOnClickListener(v -> { if (isDestroy) { finish(); } else { onBackPressed(); } }); }
下面回到TextInputActivity中。
private MaterialToolbar toolbar;//工具栏
然后在initView中,增加如下代码:
setStatubar(this, R.color.white, true); toolbar = findViewById(R.id.toolbar); back(toolbar, false);
然后你再运行就是白色的背景了,
至此,这个文字输入页面的转移就完成了。
二、新增语音输入页面
在ui下新建一个VoiceInputActivity,布局为activity_voice_input.xml。
布局代码如下:
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true" android:orientation="vertical"> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <!--标题--> <com.google.android.material.appbar.MaterialToolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:background="@color/white" app:navigationIcon="@mipmap/icon_back"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" android:text="语音输入" android:textColor="@color/black" android:textSize="18sp" /> </com.google.android.material.appbar.MaterialToolbar> <!--列表--> <androidx.recyclerview.widget.RecyclerView android:id="@+id/rv_result" android:layout_width="match_parent" android:layout_height="match_parent" android:overScrollMode="never" android:scrollbars="none" /> </LinearLayout> <!--浮动按钮 语音搜索--> <com.google.android.material.floatingactionbutton.FloatingActionButton android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="bottom|end" android:layout_margin="@dimen/dp_20" android:onClick="voiceInput" android:src="@mipmap/icon_voice" app:backgroundTint="@color/white" app:backgroundTintMode="screen" app:fabSize="auto" app:hoveredFocusedTranslationZ="@dimen/dp_18" app:pressedTranslationZ="@dimen/dp_18" /> </FrameLayout>
然后继承BaseActivity,代码如下:
package com.llw.goodtrash.ui; import android.os.Bundle; import android.view.View; import com.google.android.material.appbar.MaterialToolbar; import com.llw.goodtrash.R; import com.llw.mvplibrary.base.BaseActivity; /** * 语音输入物品进行垃圾分类 */ public class VoiceInputActivity extends BaseActivity { private MaterialToolbar toolbar; @Override public void initData(Bundle savedInstanceState) { initView(); } private void initView() { setStatubar(this, R.color.white, true); toolbar = findViewById(R.id.toolbar); back(toolbar, false); } @Override public int getLayoutId() { return R.layout.activity_voice_input; } public void voiceInput(View view) { showMsg("语音"); } }
三、导入讯飞SDK
这个语音页面主要的核心还是在语音上,这里我使用讯飞语音识别SDK,首先把讯飞SDK引入进来。首先登陆讯飞开放平台
可以看到我这里已经创建了三个应用,都是在对应的文章中提到过,下面创建新应用。
填写好资料后,提交。
然后下载SDK
下载后解压,里面的内容如下所示:
首先把libs下的文件复制到app模块下的libs中。
你可以看到现在Msc.jar是无法展开的,这说明还没有引入。
然后打开app的build.gradle在android{}闭包配置如下代码:
//修改APP加载资源目录 sourceSets { main { jniLibs.srcDir 'libs' jni.srcDirs = [] //disable automatic ndk-build } }
然后Sync Now 这时你的jar可以展开,则说明你的SDK引入成功,如下图所示:
在main下新建一个assets文件夹,将文件中的assets下的文件粘贴到刚才创建的这个文件夹下,如下图所示:
然后在TrashApplication的onCreate方法中进行SDK的初始化,这里需要用到APPID,就是在平台上创建应用时生成的。
这里请使用自己创建应用时生成的APPID,我的你拿去也没有用。
四、请求权限
语音输入需要使用麦克风,这个是需要权限的,因此要动态申请,这个权限申请有两种方式,第一个是在应用安装运行时申请,通常是在引导页或者欢迎页,第二个就是在用户需要使用这个功能时才去请求权限,乍一看好像用到时再申请合理一些,不过这也和APP的权限数量有关系,现在普遍的都是在启动页面一次申请APP用到的所有权限,让用户一次性通过,后面就不管了这个了,这种做法是居多的。而我们目前只有一个麦克风权限所以就直接在用的地方申请好了。
这里的权限我会用到第三方依赖库,而这个依赖也是放到mvplibrary下进行统一的管理,打开mvplibrary模块下的build.gradle,在dependencies{}闭包下添加如下依赖:
//权限请求框架 api 'com.tbruyelle.rxpermissions2:rxpermissions:0.9.4@aar'
然后Sync Now。
下面回到iVoiceInputActivity,先完成权限的请求,添加 requestPermission() 方法,代码如下:
/** * 请求权限 */ @SuppressLint("CheckResult") private void requestPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { //Android6.0及以上版本 rxPermissions.request(Manifest.permission.RECORD_AUDIO).subscribe(granted -> { if (granted) {//权限通过 //开始语音监听 } else {//权限未通过 showMsg("权限未通过,你不能使用该功能"); } }); } else { //Android6.0以下无须动态请求 //开始语音监听 } }
很简单的一个方法,这里还需要在点击浮动按钮时调用requestPermission()方法。
/** * 悬浮按钮点击 */ public void voiceInput(View view) { //请求权限 requestPermission(); }
五、语音配置
这里的语音配置就用我之前写的一个工具类好了,首先在app模块下的utils包下新建一个JsonParser类,里面的代码如下:
package com.llw.goodtrash.utils; import org.json.JSONArray; import org.json.JSONObject; import org.json.JSONTokener; /** * Json结果解析类 * * @author llw * @date 2021/4/1 15:44 */ public class JsonParser { public static String parseIatResult(String json) { StringBuffer ret = new StringBuffer(); try { JSONTokener tokener = new JSONTokener(json); JSONObject joResult = new JSONObject(tokener); JSONArray words = joResult.getJSONArray("ws"); for (int i = 0; i < words.length(); i++) { // 转写结果词,默认使用第一个结果 JSONArray items = words.getJSONObject(i).getJSONArray("cw"); JSONObject obj = items.getJSONObject(0); ret.append(obj.getString("w")); // 如果需要多候选结果,解析数组其他字段 // for(int j = 0; j < items.length(); j++) // { // JSONObject obj = items.getJSONObject(j); // ret.append(obj.getString("w")); // } } } catch (Exception e) { e.printStackTrace(); } return ret.toString(); } public static String parseGrammarResult(String json) { StringBuffer ret = new StringBuffer(); try { JSONTokener tokener = new JSONTokener(json); JSONObject joResult = new JSONObject(tokener); JSONArray words = joResult.getJSONArray("ws"); for (int i = 0; i < words.length(); i++) { JSONArray items = words.getJSONObject(i).getJSONArray("cw"); for(int j = 0; j < items.length(); j++) { JSONObject obj = items.getJSONObject(j); if(obj.getString("w").contains("nomatch")) { ret.append("没有匹配结果."); return ret.toString(); } ret.append("【结果】" + obj.getString("w")); ret.append("【置信度】" + obj.getInt("sc")); ret.append("\n"); } } } catch (Exception e) { e.printStackTrace(); ret.append("没有匹配结果."); } return ret.toString(); } public static String parseLocalGrammarResult(String json) { StringBuffer ret = new StringBuffer(); try { JSONTokener tokener = new JSONTokener(json); JSONObject joResult = new JSONObject(tokener); JSONArray words = joResult.getJSONArray("ws"); for (int i = 0; i < words.length(); i++) { JSONArray items = words.getJSONObject(i).getJSONArray("cw"); for(int j = 0; j < items.length(); j++) { JSONObject obj = items.getJSONObject(j); if(obj.getString("w").contains("nomatch")) { ret.append("没有匹配结果."); return ret.toString(); } ret.append("【结果】" + obj.getString("w")); ret.append("\n"); } } ret.append("【置信度】" + joResult.optInt("sc")); } catch (Exception e) { e.printStackTrace(); ret.append("没有匹配结果."); } return ret.toString(); } public static String parseTransResult(String json, String key) { StringBuffer ret = new StringBuffer(); try { JSONTokener tokener = new JSONTokener(json); JSONObject joResult = new JSONObject(tokener); String errorCode = joResult.optString("ret"); if(!errorCode.equals("0")) { return joResult.optString("errmsg"); } JSONObject transResult = joResult.optJSONObject("trans_result"); ret.append(transResult.optString(key)); /*JSONArray words = joResult.getJSONArray("results"); for (int i = 0; i < words.length(); i++) { JSONObject obj = words.getJSONObject(i); ret.append(obj.getString(key)); }*/ } catch (Exception e) { e.printStackTrace(); } return ret.toString(); } }
这个类用来解析语音识别到的结果。下面同样在这个包下新建一个SpeechUtil类,里面的代码如下:
package com.llw.goodtrash.utils; import android.app.Activity; import android.content.Context; import android.content.SharedPreferences; import android.graphics.Paint; import android.os.Bundle; import android.os.Environment; import android.os.MemoryFile; import android.util.Log; import android.widget.TextView; import android.widget.Toast; import com.iflytek.cloud.ErrorCode; import com.iflytek.cloud.InitListener; import com.iflytek.cloud.RecognizerResult; import com.iflytek.cloud.SpeechConstant; import com.iflytek.cloud.SpeechError; import com.iflytek.cloud.SpeechEvent; import com.iflytek.cloud.SpeechRecognizer; import com.iflytek.cloud.SpeechSynthesizer; import com.iflytek.cloud.SynthesizerListener; import com.iflytek.cloud.msc.util.FileUtil; import com.iflytek.cloud.msc.util.log.DebugLog; import com.iflytek.cloud.ui.RecognizerDialog; import com.iflytek.cloud.ui.RecognizerDialogListener; import org.json.JSONException; import org.json.JSONObject; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Objects; import java.util.Vector; /** * 语音工具类 * * @author llw */ public class SpeechUtil { private static final String TAG = "SpeechUtil"; private static Context mContext; // 语音合成对象 public static SpeechSynthesizer mTts; //播放的文字 private static String defaultText = "富强、明主、文明、和谐、自由、平等、公正、法制、爱国、敬业、诚信、友善。"; // 引擎类型 private static String mEngineType = SpeechConstant.TYPE_CLOUD; private static Vector<byte[]> container = new Vector<>(); //内存文件 private static MemoryFile memoryFile; //总大小 public static volatile long mTotalSize = 0; // 默认发音人 private static String voicer = "xiaoyan"; //语速 private static String speedValue = "50"; //音调 private static String pitchValue = "50"; //音量 private static String volumeValue = "50"; private static TextView tvState; /****************语音识别********************/ private static SpeechRecognizer mIat;// 语音听写对象 private static RecognizerDialog mIatDialog;// 语音听写UI // 用HashMap存储听写结果 private static HashMap<String, String> mIatResults = new LinkedHashMap<String, String>(); private static SharedPreferences mSharedPreferences;//缓存 private static String language = "zh_cn";//识别语言 private static String resultType = "json";//结果内容数据格式 private static String dictationResults;//听写结果 /** * 初始化监听。 */ private static InitListener mTtsInitListener = code -> { Log.i(TAG, "InitListener init() code = " + code); if (code != ErrorCode.SUCCESS) { Log.i(TAG, "初始化失败,错误码:" + code); } else { Log.i(TAG, "初始化成功"); } }; /** * 初始化 * * @param context */ public static void init(Context context) { mContext = context; /*************** 语音合成 *****************/ // 初始化合成对象 mTts = SpeechSynthesizer.createSynthesizer(mContext, mTtsInitListener); /*************** 语音听写 *****************/ // 使用SpeechRecognizer对象,可根据回调消息自定义界面; mIat = SpeechRecognizer.createRecognizer(mContext, mInitListener); // 使用UI听写功能,请根据sdk文件目录下的notice.txt,放置布局文件和图片资源 mIatDialog = new RecognizerDialog(mContext, mInitListener); mSharedPreferences = mContext.getSharedPreferences("ASR", Activity.MODE_PRIVATE); } /** * 合成回调监听。 */ private static SynthesizerListener mTtsListener = new SynthesizerListener() { //开始播放 @Override public void onSpeakBegin() { Log.i(TAG, "开始播放"); } //暂停播放 @Override public void onSpeakPaused() { Log.i(TAG, "暂停播放"); } //继续播放 @Override public void onSpeakResumed() { Log.i(TAG, "继续播放"); } //合成进度 @Override public void onBufferProgress(int percent, int beginPos, int endPos, String info) { Log.i(TAG, "合成进度:" + percent + "%"); } //播放进度 @Override public void onSpeakProgress(int percent, int beginPos, int endPos) { // 播放进度 Log.i(TAG, "播放进度:" + percent + "%"); tvState.setText("播报中"); } //播放完成 @Override public void onCompleted(SpeechError error) { tvState.setText("播报完成"); if (error == null) { Log.i(TAG, "播放完成," + container.size()); DebugLog.LogD("播放完成," + container.size()); for (int i = 0; i < container.size(); i++) { //写入文件 writeToFile(container.get(i)); } //保存文件 FileUtil.saveFile(memoryFile, mTotalSize, mContext.getExternalFilesDir(null) + "/1.pcm"); } else { //异常信息 showTip(error.getPlainDescription(true)); } tvState.setText(""); } //事件 @Override public void onEvent(int eventType, int arg1, int arg2, Bundle obj) { // 以下代码用于获取与云端的会话id,当业务出错时将会话id提供给技术支持人员,可用于查询会话日志,定位出错原因 // 若使用本地能力,会话id为null if (SpeechEvent.EVENT_SESSION_ID == eventType) { String sid = obj.getString(SpeechEvent.KEY_EVENT_SESSION_ID); Log.i(TAG, "session id =" + sid); } //当设置SpeechConstant.TTS_DATA_NOTIFY为1时,抛出buf数据 if (SpeechEvent.EVENT_TTS_BUFFER == eventType) { byte[] buf = obj.getByteArray(SpeechEvent.KEY_EVENT_TTS_BUFFER); Log.i(TAG, "bufis =" + buf.length); container.add(buf); } } }; /** * 写入文件 */ private static void writeToFile(byte[] data) { if (data == null || data.length == 0) { return; } try { if (memoryFile == null) { Log.i(TAG, "memoryFile is null"); String mFilepath = mContext.getExternalFilesDir(null) + "/1.pcm"; memoryFile = new MemoryFile(mFilepath, 1920000); memoryFile.allowPurging(false); } memoryFile.writeBytes(data, 0, (int) mTotalSize, data.length); mTotalSize += data.length; } catch (Exception e) { e.printStackTrace(); } } /** * 参数设置 * * @return */ private static void setParam() { // 清空参数 mTts.setParameter(SpeechConstant.PARAMS, null); // 根据合成引擎设置相应参数 if (mEngineType.equals(SpeechConstant.TYPE_CLOUD)) { mTts.setParameter(SpeechConstant.ENGINE_TYPE, SpeechConstant.TYPE_CLOUD); //支持实时音频返回,仅在synthesizeToUri条件下支持 mTts.setParameter(SpeechConstant.TTS_DATA_NOTIFY, "1"); // 设置在线合成发音人 mTts.setParameter(SpeechConstant.VOICE_NAME, voicer); //设置合成语速 mTts.setParameter(SpeechConstant.SPEED, speedValue); //设置合成音调 mTts.setParameter(SpeechConstant.PITCH, pitchValue); //设置合成音量 mTts.setParameter(SpeechConstant.VOLUME, volumeValue); } else { mTts.setParameter(SpeechConstant.ENGINE_TYPE, SpeechConstant.TYPE_LOCAL); mTts.setParameter(SpeechConstant.VOICE_NAME, ""); } // 设置播放合成音频打断音乐播放,默认为true mTts.setParameter(SpeechConstant.KEY_REQUEST_FOCUS, "false"); // 设置音频保存路径,保存音频格式支持pcm、wav mTts.setParameter(SpeechConstant.AUDIO_FORMAT, "pcm"); mTts.setParameter(SpeechConstant.TTS_AUDIO_PATH, mContext.getExternalFilesDir(null) + "/msc/tts.pcm"); } /** * 开始语音预报 */ public static void startVoiceBroadcast(String text, TextView textView) { tvState = textView; if (mTts == null) { showTip("创建对象失败,请确认 libmsc.so 放置正确,且有调用 createUtility 进行初始化"); return; } if (text == null || text.isEmpty()) { text = defaultText; } //设置参数 setParam(); //开始合成播放 int code = mTts.startSpeaking(text, mTtsListener); if (code != ErrorCode.SUCCESS) { showTip("语音合成失败,错误码: " + code); } } /** * 初始化语音听写监听器 */ private static InitListener mInitListener = code -> { Log.d(TAG, "SpeechRecognizer init() code = " + code); if (code != ErrorCode.SUCCESS) { showTip("初始化失败,错误码:" + code + ",请点击网址https://www.xfyun.cn/document/error-code查询解决方案"); } }; /** * 听写UI监听器 */ private static RecognizerDialogListener mRecognizerDialogListener = new RecognizerDialogListener() { /** * 识别结果 */ @Override public void onResult(RecognizerResult results, boolean isLast) { parsingResult(results);//结果数据解析 } /** * 识别回调错误 */ @Override public void onError(SpeechError error) { showTip(error.getPlainDescription(true)); } }; /** * 语音识别结果数据解析 * * @param results */ private static void parsingResult(RecognizerResult results) { //获取解析结果 String text = JsonParser.parseIatResult(results.getResultString()); String sn = null; // 读取json结果中的sn字段 try { JSONObject resultJson = new JSONObject(results.getResultString()); sn = resultJson.optString("sn"); } catch (JSONException e) { e.printStackTrace(); } mIatResults.put(sn, text); StringBuffer resultBuffer = new StringBuffer(); for (String key : mIatResults.keySet()) { resultBuffer.append(mIatResults.get(key)); } dictationResults = resultBuffer.toString();//听写结果显示 //回调 mSpeechCallback.dictationResults(dictationResults); Log.d(TAG,dictationResults); } /** * 听写参数设置 * * @return */ public static void setDictationParam() { // 清空参数 mIat.setParameter(SpeechConstant.PARAMS, null); // 设置听写引擎 mIat.setParameter(SpeechConstant.ENGINE_TYPE, mEngineType); // 设置返回结果格式 mIat.setParameter(SpeechConstant.RESULT_TYPE, resultType); if (language.equals("zh_cn")) { String lag = mSharedPreferences.getString("iat_language_preference", "mandarin"); Log.e(TAG, "language:" + language);// 设置语言 mIat.setParameter(SpeechConstant.LANGUAGE, "zh_cn"); // 设置语言区域 mIat.setParameter(SpeechConstant.ACCENT, lag); } else { mIat.setParameter(SpeechConstant.LANGUAGE, language); } Log.e(TAG, "last language:" + mIat.getParameter(SpeechConstant.LANGUAGE)); //此处用于设置dialog中不显示错误码信息 //mIat.setParameter("view_tips_plain","false"); // 设置语音前端点:静音超时时间,即用户多长时间不说话则当做超时处理 mIat.setParameter(SpeechConstant.VAD_BOS, mSharedPreferences.getString("iat_vadbos_preference", "4000")); // 设置语音后端点:后端点静音检测时间,即用户停止说话多长时间内即认为不再输入, 自动停止录音 mIat.setParameter(SpeechConstant.VAD_EOS, mSharedPreferences.getString("iat_vadeos_preference", "1000")); // 设置标点符号,设置为"0"返回结果无标点,设置为"1"返回结果有标点 mIat.setParameter(SpeechConstant.ASR_PTT, mSharedPreferences.getString("iat_punc_preference", "1")); // 设置音频保存路径,保存音频格式支持pcm、wav,设置路径为sd卡请注意WRITE_EXTERNAL_STORAGE权限 mIat.setParameter(SpeechConstant.AUDIO_FORMAT, "wav"); mIat.setParameter(SpeechConstant.ASR_AUDIO_PATH, Environment.getExternalStorageDirectory() + "/msc/iat.wav"); } /** * 开始听写 */ public static void startDictation(SpeechCallback speechCallback){ mSpeechCallback = speechCallback; if( null == mIat ){ // 创建单例失败,与 21001 错误为同样原因,参考 http://bbs.xfyun.cn/forum.php?mod=viewthread&tid=9688 showTip( "创建对象失败,请确认 libmsc.so 放置正确,且有调用 createUtility 进行初始化" ); return; } mIatResults.clear();//清除数据 setDictationParam(); // 设置参数 mIatDialog.setListener(mRecognizerDialogListener);//设置监听 mIatDialog.show();// 显示对话框 //获取字体所在的控件 TextView tvLink = Objects.requireNonNull(mIatDialog.getWindow()).getDecorView().findViewWithTag("textlink"); tvLink.setText(" "); tvLink.getPaint().setFlags(Paint.SUBPIXEL_TEXT_FLAG);//取消下划线 tvLink.setEnabled(false);//禁用点击 } /** * Toast提示 * * @param msg */ private static void showTip(String msg) { Toast.makeText(mContext, msg, Toast.LENGTH_SHORT).show(); } //语音回调 private static SpeechCallback mSpeechCallback; /** * 语音回调接口 */ public interface SpeechCallback { /** * 听写结果 */ void dictationResults(String goodsName); } }
我建议你先实现功能,然后再去了解里面做了什么,如果你时间不够的话。那么这个工具类自然要在VoiceInputActivity中进行配置使用的,下面进入VoiceInputActivity,下面先声明一些变量
private static final String TAG = "VoiceInputActivity"; private MaterialToolbar toolbar;//工具栏 private RxPermissions rxPermissions;//异步权限请求框架 private List<TrashResponse.NewslistBean> newslistBeanList = new ArrayList<>();//数据列表 private SearchGoodsAdapter searchGoodsAdapter;//结果列表适配器 private RecyclerView rvResult;//结果列表
然后写一个初始化的initView()方法,在initData中调用。initView()方法代码如下:
private void initView() { toolbar = findViewById(R.id.toolbar); rvResult = findViewById(R.id.rv_result); //设置页面状态栏 setStatubar(this, R.color.white, true); //设置返回工具栏图标点击事件 back(toolbar, false); //实例化 rxPermissions = new RxPermissions(this); //初始化语音 SpeechUtil.init(context); //配置适配器 设置布局和数据源 searchGoodsAdapter = new SearchGoodsAdapter(R.layout.item_search_rv, newslistBeanList); //设置列表的布局管理器 rvResult.setLayoutManager(new LinearLayoutManager(this)); //列表item点击事件 searchGoodsAdapter.setOnItemChildClickListener((adapter, view, position) -> { showMsg("点击了" + newslistBeanList.get(position).getName()); }); //设置列表适配器 rvResult.setAdapter(searchGoodsAdapter); }
initData中调用
@Override public void initData(Bundle savedInstanceState) { //页面初始化 initView(); }
然后你可以使用TextContract,或者重写一个VoiceContract,这两个类目前来说内容一致,因此你可以用一个来解决。不过我这里还是写了一个VoiceContract类,放在contract包下,里面的代码如下:
package com.llw.goodtrash.contract; import android.annotation.SuppressLint; import com.llw.goodtrash.api.ApiService; import com.llw.goodtrash.model.TrashResponse; import com.llw.mvplibrary.base.BasePresenter; import com.llw.mvplibrary.base.BaseView; import com.llw.mvplibrary.network.NetworkApi; import com.llw.mvplibrary.network.observer.BaseObserver; /** * 语音文字输入页面访问网络 * * @author llw * @date 2021/4/1 16:03 */ public class VoiceContract { public static class VoicePresenter extends BasePresenter<VoiceView> { /** * 搜索物品 * * @param word 物品名 */ @SuppressLint("CheckResult") public void searchGoods(String word) { ApiService service = NetworkApi.createService(ApiService.class); service.searchGoods(word).compose(NetworkApi.applySchedulers(new BaseObserver<TrashResponse>() { @Override public void onSuccess(TrashResponse groupResponse) { if (getView() != null) { getView().getSearchResponse(groupResponse); } } @Override public void onFailure(Throwable e) { if (getView() != null) { getView().getSearchResponseFailed(e); } } })); } } public interface VoiceView extends BaseView { /** * 搜索物品返回 * * @param response */ void getSearchResponse(TrashResponse response); /** * 搜索物品异常返回 * * @param throwable */ void getSearchResponseFailed(Throwable throwable); } }
然后回到VoiceInputActivity,修改代码如下图;
然后
@Override protected VoiceContract.VoicePresenter createPresenter() { return new VoiceContract.VoicePresenter(); } /** * 搜索物品返回数据 * * @param response */ @Override public void getSearchResponse(TrashResponse response) { //隐藏加载弹窗 hideLoadingDialog(); if (response.getCode() == Constant.SUCCESS_CODE) { //请求成功 进行数据的渲染 if (response.getNewslist() != null && response.getNewslist().size() > 0) { newslistBeanList.clear(); newslistBeanList.addAll(response.getNewslist()); //刷新适配器 searchGoodsAdapter.notifyDataSetChanged(); } else { showMsg("触及到了知识盲区"); } } else { //显示请求接口失败的原因 showMsg(response.getMsg()); } } /** * 搜索物品失败返回 * * @param throwable 异常信息 */ @Override public void getSearchResponseFailed(Throwable throwable) { hideLoadingDialog(); Log.e(TAG, throwable.toString()); }
这里的代码基本你都见过了。
六、显示数据
在VoiceInputActivity新增一个方法,代码如下:
/** * 开始语音监听 */ private void startVoiceListener() { SpeechUtil.startDictation(goodsName -> { if (goodsName.isEmpty()) { return; } //判断字符串是否包含句号 if (goodsName.contains("。")) { return; } //请求接口搜索物品的垃圾分类 showMsg("正在搜索物品:" + goodsName); mPresenter.searchGoods(goodsName); }); }
然后在requestPermission中调用。
下面运行一下:
OK,这样功能就实现了,本文就到这里了。