Android 天气APP(三十四)语音搜索

Android 天气APP(三十四)语音搜索,第1张

概述上一篇:Android天气APP(三十三)语音播报语音搜索前言正文一、权限配置二、用户体验优化三、配置语音识别听写四、语音搜索五、地图天气添加语音搜索功能六、城市搜索添加语音搜索功能总结前言  在上一篇文章中,给天气APP添加了语音播报的功能,但是主页面要是想去切换

上一篇:Android 天气APP(三十三)语音播报

语音搜索前言正文一、权限配置二、用户体验优化三、配置语音识别听写四、语音搜索五、地图天气添加语音搜索功能六、城市搜索添加语音搜索功能总结


前言

  在上一篇文章中,给天气APP添加了语音播报的功能,但是主页面要是想去切换城市除了已有常用城市以外,切换城市和搜索城市需要的操作都太多了,因此通过语音来搜索城市,然后查询天气无疑可以简化操作步骤。


正文

  之前在加入语音播报时就已经配置好了讯飞的SDK,因此,@R_419_7004@直接写功能就可以了,下面开始写功能吧。

一、权限配置

  语音搜索,则首先需要手机能听到我们说话。因此你需要在app模块下的AndroIDManifest.xml中添加一个录音权限。

<uses-permission androID:name="androID.permission.RECORD_AUdio"/><!--录音-->

同时这个权限属于危险权限,因此需要动态申请。还记得我们之前请求定位权限的地方吗,就在欢迎页中,那么只需要把这个权限加入进去就可以了。


有了权限就可以去做后面的事情了,现在需要想一个问题,那就是在什么地方以怎样的形式去进行语音搜索,可以在主页面中通过按钮来触发语音的监听。

二、用户体验优化

  首先明确一点,语音搜索功能并不是必须的,这属于锦上添花,但是并不是每一个用户都会这么认为,这一点要明确,正所谓总口难调,为了避免软件功能成为众矢之的,所以在增加新功能时,要考虑的全面一些,减少用户的反面情绪。因此这个语音搜索功能也要可以关闭才行。说到这个关闭你有没有想到之前的每日弹窗呢。没错,我们可以把两个开关放在同一个设置页面里面,那么首先来完成这一步吧。

打开activity_setting.xml,在每日弹窗的后面增加如下布局代码:

	<!--语音搜索-->    <linearLayout        androID:layout_wIDth="match_parent"        androID:layout_height="wrap_content"        androID:layout_margintop="@dimen/dp_1"        androID:background="@color/white"        androID:gravity="center_vertical"        androID:orIEntation="horizontal"        androID:paddingleft="@dimen/dp_16"        androID:paddingtop="@dimen/dp_8"        androID:paddingRight="@dimen/dp_16"        androID:paddingBottom="@dimen/dp_8">        <TextVIEw            androID:layout_wIDth="0dp"            androID:layout_height="wrap_content"            androID:layout_weight="1"            androID:text="语音搜索"            androID:textcolor="@color/black"            androID:textSize="@dimen/sp_16" />        <com.llw.mvplibrary.vIEw.Switchbutton            androID:ID="@+ID/wb_voice_search"            androID:layout_wIDth="wrap_content"            androID:layout_height="wrap_content" />    </linearLayout>

如下图所示


布局改好了之后,进入SettingActivity,绑定ID。

	@BindVIEw(R.ID.wb_voice_search)    Switchbutton wbVoiceSearch;//语音搜索开关

那么现@R_419_7004@有两个开关按钮,为了不写重复代码,这里可以写一个方法来控制,在此之前先来看看原来的每日弹窗的代码是怎么写的。


可以看到,这里的代码分为两部分,上部分取缓存中的值,设置是否打开每日弹窗开关,下部分用来监听开关按钮是否打开,然后重新设置缓存。之前是通过一个全局变量来控制每日开关,那么同样也要通过一个变量来控制语音搜索开关。打开Constant,增加如下变量代码:

	/**     * 语音搜索是否关闭     */    public static final String VOICE_SEARCH_BOolEAN = "voiceSearchBoolean";

变量有了,在SettingActivity中新增setSwitch方法,代码如下:

	/**     * 设置Switch     */    private voID setSwitch(Switchbutton switchbutton, final int type) {        wbEveryday.setChecked(SPUtils.getBoolean(Constant.EVERYDAY_POP_BOolEAN, true, context));        wbVoiceSearch.setChecked(SPUtils.getBoolean(Constant.VOICE_SEARCH_BOolEAN, true, context));        switchbutton.setonCheckedchangelistener((vIEw, isChecked) -> {            switch (type) {                case 1:                    if (isChecked) {                        SPUtils.putBoolean(Constant.EVERYDAY_POP_BOolEAN, true, context);                    } else {                        SPUtils.putBoolean(Constant.EVERYDAY_POP_BOolEAN, false, context);                    }                    break;                case 2:                    if (isChecked) {                        SPUtils.putBoolean(Constant.VOICE_SEARCH_BOolEAN, true, context);                    } else {                        SPUtils.putBoolean(Constant.VOICE_SEARCH_BOolEAN, false, context);                    }                    break;                default:                    break;            }        });    }

然后在initData中调用

设置页面的代码就写好了,下面写主页面的代码,打开activity_main.xml。
增加浮动按钮代码。

	<!--浮动按钮 语音搜索-->    <com.Google.androID.material.floatingactionbutton.floatingActionbutton        androID:ID="@+ID/fab_voice_search"        androID:layout_wIDth="wrap_content"        androID:layout_height="wrap_content"        androID:layout_gravity="bottom|end"        androID:layout_margin="@dimen/dp_20"        androID:clickable="true"        androID:src="@mipmap/icon_voice_search"        app:backgroundTint="@color/white"        app:backgroundTintMode="screen"        app:fabSize="mini"        app:hoveredFocusedTranslationZ="@dimen/dp_18"        app:pressedTranslationZ="@dimen/dp_18" />


这是按钮的图标,添加代码的位置如下所示:


进入主页面MainActivity,绑定ID。

	@BindVIEw(R.ID.fab_voice_search)    floatingActionbutton fabVoiceSearch;//语音搜索浮动按钮

然后在onResume方法回调中。

		//是否显示语音搜索按钮        if (SPUtils.getBoolean(Constant.VOICE_SEARCH_BOolEAN, true, context)) {            fabVoiceSearch.show();        } else {            fabVoiceSearch.hIDe();        }

@R_419_7004@通过缓存变量值来控制是否显示这个按钮,默认是的显示这个按钮,而当你去设置中关闭开关之后,这个按钮就不再显示了。

三、配置语音识别听写

  前面说到了有这个按钮,那么点击这个按钮自然要做一些事情,下面来看看做什么事情。还记得在上篇文章中我新增了一个语音工具类SpeechUtil。下面的配置,同样要写在这个工具类中,理由同样是,让主页面的代码逻辑更清晰和简洁,同时方便其他页面调用。当然如果你只是想在一个页面中使用的话,可以看看这一篇文章Android 科大讯飞语音识别,下面进入到SpeechUtil。

先创建成员变量

	/****************语音识别********************/    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;//听写结果

然后新增mInitListener变量完成对语音SDK初始化的监听,这里其实和语音合成用的是一样的InitListener ,只是用了不同的变量名来接收,可以更精简一些,如果你是自己写的话,就直接用一个变量就好了。

	/**     * 初始化语音听写监听器     */    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 one rror(SpeechError error) {            showTip(error.getPlainDescription(true));        }    };

下面在写parsingResult方法之前,先做好一些准备工作。首先在你的app模块下的utils包下新建一个JsonParser类,里面的代码如下:

package com.llw.gooDWeather.utils;import org.Json.JsONArray;import org.Json.JsONObject;import org.Json.JsONTokener;/** * Json结果解析类 */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中新增如下接口。

	//语音回调    private static SpeechCallback mSpeechCallback;    /**     * 语音回调接口     */    public interface SpeechCallback {        /**         * 听写结果         */        voID dictationResults(String cityname);    }

并创建一个变量,下面就可以编写parsingResult方法了,代码如下:

	/**     * 语音识别结果数据解析     *     * @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);    }

然后是配置语音识别的参数,新增setDictationParam方法。

	/**     * 听写参数设置     *     * @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");    }

然后编写语音识别的startDictation方法,代码如下:

	/**     * 开始听写     */    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();// 显示对话框    }

还有最后一步,那就是初始化,还记得init方法吗?

		// 使用SpeechRecognizer对象,可根据回调消息自定义界面;        mIat = SpeechRecognizer.createRecognizer(mContext, mInitListener);        // 使用UI听写功能,请根据sdk文件目录下的notice.txt,放置布局文件和图片资源        mIatDialog = new RecognizerDialog(mContext, mInitListener);        mSharedPreferences = mContext.getSharedPreferences("ASR",                Activity.MODE_PRIVATE);

添加位置如下图所示:


最后就只要在MainActivity中调用就可以了。

四、语音搜索

  进入到MainActivity,首先给浮动按钮添加点击事件。



然后通过startDictation方法。

				SpeechUtil.startDictation(new SpeechUtil.SpeechCallback() {                    @OverrIDe                    public voID dictationResults(String cityname) {                        if(cityname.isEmpty()){                            return;                        }                        ToastUtils.showShortToast(context,cityname);                    }                });

这里可以通过lambda表达式进行一下简化,就是这样:

				SpeechUtil.startDictation(cityname -> {                    if(cityname.isEmpty()){                        return;                    }                    ToastUtils.showShortToast(context,cityname);                });

下面运行测试一下,请通过真机运行,然后通过录制音频权限。到主页面,点击右下角的浮动按钮,会出现一个弹窗,然后说出一个城市的名字,我这里说的是长沙,演示效果图如下所示:


这样就拿到了城市,下面就可以通过这个城市的值去搜索城市,然后获取城市的ID,之后就可以查询天气数据了,是不是很简单呢?不过刚才出现的语音弹窗有一个小问题,那就是它的底部有一行小字体链接,如果你点击则会进入讯飞的官网,这么一看就像是在打广告了,所以要去掉这一行字,那么怎么去呢?这是一个问题。打开assets中iflytek文件夹下的recognize.xml文件夹,你会看到一些乱码,就像下面的图这样。


Don’t worry,从之前的弹窗我们得知这是一个超链接文本,那么你就可以从这些乱码中去寻找有关于超链接的字眼?链接的英文是什么?link啊!
然后你Ctrl + F ,搜索link。


这个autolink好像不对,点一下回车。


这个textlink,好像差不多,那么就试一下这个。通过这个命名我有理由相信这是一个控件的ID,那么它是textlink,文本链接,那么很有可能就是TextVIEw控件,然后添加了点击事件和下划线形成的,那么下面来验证我的这个判断。还记得我们是在什么地方显示这个弹窗的吗?

没错就是在SpeechUtil的startDictation方法中,我们可以在弹窗显示之后。添加如下代码。

		//获取字体所在的控件        TextVIEw tvlink = Objects.requireNonNull(mIatDialog.getwindow()).getDecorVIEw().findVIEwWithTag("textlink");        tvlink.setText(" ");        tvlink.getPaint().setFlags(Paint.SUBPIXEL_TEXT_FLAG);//取消下划线        tvlink.setEnabled(false);//禁用点击

添加位置如下所示:


下面运行看看。

是不是没有这个底部的广告了呢?嗯,歪打正着,很Nice!程序员的快乐有时候就是这么简单。
OK,下面要做的就很简单了,就是处理这个搜索城市的结果,然后发起请求就可以了。

那么下面修改点击浮动按钮中的代码如下:

					//判断字符串是否包含句号                    if (!cityname.contains("。")) {                        //然后判断成员变量和临时变量是否一样,不一样则赋值。                        if (!district.equals(cityname)) {                            district = cityname;                            Log.d("city",district);                            //加载弹窗                            showLoadingDialog();                            ToastUtils.showShortToast(context, "正在搜索城市:"+district+",请稍后...");                            flag = false;//不属于定位,则不需要显示定位图标                            //搜索城市                            mPresent.newSearchCity(district);                        }                    }

改动如下图所示:


这样就搞定了,主页面就有了语音搜索的功能了,还有几个页面也可以添加这个功能。

五、地图天气添加语音搜索功能

  打开activity_map_weather.xml,这个页面要是添加语音搜索功能也比较简单,直接在这个拖动区域中添加一个按钮图标即可,如下图所示


图标使用白色的麦克风图标,可以去我的源码里面去拿。

修改部分的布局代码如下:

	 			<relativeLayout                    androID:layout_wIDth="match_parent"                    androID:layout_height="wrap_content">                    <!--城市-->                    <TextVIEw                        androID:ID="@+ID/tv_city"                        androID:layout_wIDth="wrap_content"                        androID:layout_height="wrap_content"                        androID:text="城市"                        androID:textcolor="@color/white"                        androID:textSize="@dimen/sp_16" />                    <ImageVIEw                        androID:ID="@+ID/voice_search"                        androID:layout_wIDth="@dimen/dp_40"                        androID:layout_height="wrap_content"                        androID:layout_alignParentRight="true"                        androID:src="@mipmap/icon_voice_search_white" />                </relativeLayout>

添加位置如下图所示:


下面进入到MapWeatherActivity,先绑定控件

	@BindVIEw(R.ID.voice_search)    ImageVIEw voiceSearch;//语音搜索

然后添加点击事件


然后在initData方法中完成初始化。


然后在点击事件中添加如下代码:

				SpeechUtil.startDictation(cityname -> {                    if (cityname.isEmpty()) {                        return;                    }                    //判断字符串是否包含句号                    if (!cityname.contains("。")) {                        geoCoder.geocode(new GeoCodeOption().city(cityname).address(cityname));                    }                });

这里拿到地址之后,首先要改变地图上的点,然后会去搜索这个城市,然后搜索天气,运行效果如下图所示:


这样地图页面的这个功能就添加完毕了。

六、城市搜索添加语音搜索功能

  首先也是先修改布局,打开activity_search_city.xml,修改的代码如下:

		<linearLayout            androID:gravity="center_vertical"            androID:layout_wIDth="match_parent"            androID:layout_height="wrap_content">            <!--输入框布局-->            <linearLayout                androID:layout_wIDth="0dp"                androID:layout_height="@dimen/dp_30"                androID:layout_marginRight="@dimen/dp_12"                androID:layout_weight="1"                androID:background="@drawable/shape_gray_bg_14"                androID:focusable="true"                androID:focusableIntouchMode="true"                androID:gravity="center_vertical"                androID:paddingleft="@dimen/dp_12"                androID:paddingRight="@dimen/dp_12">                <!--搜索图标-->                <ImageVIEw                    androID:layout_wIDth="@dimen/dp_16"                    androID:layout_height="@dimen/dp_16"                    androID:src="@mipmap/icon_search" />                <!--输入框-->                <autoCompleteTextVIEw                    androID:ID="@+ID/edit_query"                    androID:layout_wIDth="match_parent"                    androID:layout_height="wrap_content"                    androID:layout_weight="1"                    androID:background="@null"                    androID:completionThreshold="1"                    androID:dropDownHorizontalOffset="5dp"                    androID:hint="输入城市关键字"                    androID:imeOptions="actionSearch"                    androID:paddingleft="@dimen/dp_8"                    androID:paddingRight="@dimen/dp_4"                    androID:singleline="true"                    androID:textcolor="@color/black"                    androID:textCursorDrawable="@drawable/cursor_style"                    androID:textSize="@dimen/sp_14" />                <!--清除输入的内容-->                <ImageVIEw                    androID:ID="@+ID/iv_clear_search"                    androID:layout_wIDth="@dimen/dp_16"                    androID:layout_height="@dimen/dp_16"                    androID:src="@mipmap/icon_delete"                    androID:visibility="gone" />            </linearLayout>            <!--语音搜索-->            <ImageVIEw                androID:ID="@+ID/voice_search"                androID:layout_wIDth="wrap_content"                androID:layout_height="wrap_content"                androID:layout_marginRight="@dimen/dp_12"                androID:src="@mipmap/icon_voice_search" />        </linearLayout>

修改位置如下图所示:


然后同样是进入到SearchCityActivity页面,绑定ID。

	/**     * 语音搜索     */    @BindVIEw(R.ID.voice_search)    ImageVIEw voiceSearch;

然后添加点击事件,如下图所示:


然后就是在initData里面添加

		//初始化语音播报        SpeechUtil.init(this);

之后就是在点击事件中添加如下代码:

				SpeechUtil.startDictation(cityname -> {                    //判断字符串是否包含句号                    if (!cityname.contains("。")) {                        editquery.setText(cityname);                        showLoadingDialog();                        //添加数据                        mRecordsDao.addRecords(cityname);                        //搜索城市                        mPresent.newSearchCity(cityname);                        //数据保存                        saveHistory("history", editquery);                    }                });

就可以了,下面运行一下:


OK,这样语音功能就添加进去了,每个页面的业务不同,因此页面的操作也会有相应的改变,要因地制宜,不要想着一份代码在所有地方都适用,这种情况很少。


总结

  到这里本篇文章就结束了,说起来这篇文章是从2020年写到了2021年,过年回家那几天光走亲戚去了,回到家里根本不想写代码了,因此回到深圳之后花了一些时间写出来。这个天气APP的系列博客文章我居然都写到了第三十四篇了,这在之前是我不敢相信的,最开始的版本是九篇文章,其实就是一篇文章,但是由于字数太多,不让发布,所以我拆分了成了前九篇文章,然后去年一整年的时间,陆陆续续又写了21篇文章。还是挺感慨的,后续我可能还会再写下去,也可能不会写了,因为确实能跟着博客看完并且手动操作的人比较少,可能一看到这个文章有34篇,就慌了,不敢学了,望文兴叹。学习是一个循序渐进的过程,你不学,其他人就在学,到时候你怎么和别人竞争呢?天道酬勤,我是初学者-Study,山高水长,后会有期~

源码地址:Good Weather 欢迎 Star 和 Fork

联系邮箱 lonelyholIDay@qq.com

总结

以上是内存溢出为你收集整理的Android 天气APP(三十四)语音搜索全部内容,希望文章能够帮你解决Android 天气APP(三十四)语音搜索所遇到的程序开发问题。

如果觉得内存溢出网站内容还不错,欢迎将内存溢出网站推荐给程序员好友。

欢迎分享,转载请注明来源:内存溢出

原文地址:http://outofmemory.cn/web/1048111.html

(0)
打赏 微信扫一扫微信扫一扫 支付宝扫一扫支付宝扫一扫
上一篇 2022-05-25
下一篇2022-05-25

发表评论

登录后才能评论

评论列表(0条)

    保存