最近一位小姐姐在微信上向我抱怨,说自己每天坐地铁上下班,路上会阅读一些好的文章来提升自己。
但上了一天的班,实在太累了;如果戴上耳机的同时,文章能自动阅读起来,就好了!
本篇文章将带大家用自动化技术,来实现这一功能。
第1步,新建 Android 项目
使用 Android Studio 新建一个项目,并创建一个无障碍服务,设置只处理微信应用内的页面事件
- //新建一个服务
- public class MsgService extends AccessibilityService
- {
- @Override
- public void onAccessibilityEvent(AccessibilityEvent event)
- {
-
- }
- }
-
- //通过packageNames指定只处理微信App页面事件
- <?xml version="1.0" encoding="utf-8"?>
- <accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
- android:accessibilityEventTypes="typeWindowStateChanged"
- android:accessibilityFeedbackType="feedbackGeneric"
- android:accessibilityFlags="flagDefault"
- android:canRetrieveWindowContent="true"
- android:description="@string/desc"
- android:notificationTimeout="100"
- android:packageNames="com.tencent.mm" />
第2步,安装文字转语言引擎
由于系统内置的 Pico TTS 不支持中文,为了更好地将文字转为语音,这里先下载安装Google 文字转语音这款App,然后将首选引擎切换到 Google文字转语言引擎
第3步,获取公众号文章内容
使用 Android SDK 自带的uiautomatorviewer打开某一篇公众号文章的页面元素树
通过分析,发现一篇文章的正文内容都包含在控件中text属性中,因此,我们只需要遍历出所有的控件,找出所有 text 属性不为空的内容。
需要注意的是,由于微信基于腾讯 X5 内核,内容包裹在WebView 内部,直接获取控件是获取不到的,因此,需要在服务初始化的时候配置 flags 为增强
- //新建一个服务
- @Override
- protected void onServiceConnected()
- {
- super.onServiceConnected();
- AccessibilityServiceInfo serviceInfo = new AccessibilityServiceInfo();
- serviceInfo.eventTypes = AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED;
- serviceInfo.feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC;
- serviceInfo.packageNames = new String[]{"com.tencent.mm"};
- serviceInfo.notificationTimeout = 100;
-
- //保证能够获取到WebView内部的控件元素
- serviceInfo.flags = serviceInfo.flags | AccessibilityServiceInfo.FLAG_REQUEST_ENHANCED_WEB_ACCESSIBILITY;
- setServiceInfo(serviceInfo);
-
- Toast.makeText(MsgService.this, "连接服务成功",
- Toast.LENGTH_SHORT).show();
- }
-
接着,先找到 WebView 控件,然后遍历子元素,找出所有子元素 text 不为空的内容
- /***
- * 获取所有的文本内容
- * @param webNode
- * @return
- */
- private void getAllContents(AccessibilityNodeInfo webNode)
- {
- for (int i = 0; i < webNode.getChildCount(); i++)
- {
- AccessibilityNodeInfo tempNode = webNode.getChild(i);
- String id = tempNode.getViewIdResourceName();
- //过滤
- if (TextUtils.equals("meta_content", id))
- {
- continue;
- }
- String tempContent = tempNode.getText().toString().trim();
- //加入内容
- if (!TextUtils.isEmpty(tempContent))
- {
- contents.add(tempContent);
- }
- //循环遍历
- //判断是否有子节点
- if (tempNode.getChildCount() > 0)
- {
- for (int j = 0; j < tempNode.getChildCount(); j++)
- {
- getAllContents(tempNode.getChild(j));
- }
- }
- }
- }
-
最后,将文章内容分段存储到配置文件中
- StringBuilder sb = new StringBuilder();
- for (int i = 0; i < contents.size(); i++)
- {
- sb.append(contents.get(i)).append(";;;");
- Log.d("xag", contents.get(i));
- }
-
- Log.d("xag", "*******************获取完成*********************");
-
- //存储
- SpUtil.clear(BaseApplication.getInstance());
- SpUtil.put("contents", sb.toString());
-
第4步,添加悬浮框
为了更加方便地管理语音播放功能,新建一个系统悬浮窗,并设置按钮的点击事件,即:点击关闭按钮可以关闭悬浮框;点击复选框,可以切换到播放、暂停状态
- # 悬浮框依赖
- implementation 'com.github.princekin-f:EasyFloat:1.3.2'
-
- //显示悬浮框
- private void initFloatDialog()
- {
- View currentFLoat = EasyFloat.getAppFloatView("readmsg");
- if (null == currentFLoat)
- {
- //初始化悬浮框View,并新增回调事件
- EasyFloat.with(this).setLayout(R.layout.float_test, new OnInvokeView()
- {
- @Override
- public void invoke(View view)
- {
- ImageView close_iv = view.findViewById(R.id.ivClose);
- final CheckBox float_cb = view.findViewById(R.id.float_cb);
- close_iv.setOnClickListener(new View.OnClickListener()
- {
-
- @Override
- public void onClick(View v)
- {
- EasyFloat.dismissAppFloat("readmsg");
- }
- });
- //播放、停止切换功能
- float_cb.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener()
- {
- @Override
- public void onCheckedChanged(CompoundButton buttonView, boolean isChecked)
- {
-
- }
- });
-
- }
- }).setShowPattern(ShowPattern.ALL_TIME)
- .setTag("readmsg")
- .setAnimator(new DefaultAnimator())
- .setGravity(Gravity.END | Gravity.CENTER_VERTICAL, -2, 200).show();
- }
-
- if (!EasyFloat.isShow(this, "readmsg"))
- {
- EasyFloat.showAppFloat("readmsg");
- }
- }
-
第5步,过滤页面
为了提升用户体验,可以对页面进行过滤,保证只有在文章页面的时候,才显示系统悬浮框
- # 事件总线依赖
- implementation 'org.simple:androideventbus:1.0.5.1'
-
- //如果是微信公众号文章页面
- if (TextUtils.equals(currentClassName, CLASS_NAME_PAGE_ARTICLE))
- {
- //等待页面加载
- try
- {
- Thread.sleep(5000);
- } catch (InterruptedException e)
- {
- e.printStackTrace();
- }
-
- //发送显示悬浮框的事件
- EventBus.getDefault().post(new ShowFloatBean(true));
- }
-
- //订阅事件,显示或隐藏悬浮框
- @Subscriber
- private void changeFloatStatus(ShowFloatBean showFloatBean)
- {
- Log.d("xag", "接受到事件,展示或者隐藏:" + showFloatBean.isShow());
- boolean showFloat = showFloatBean.isShow();
- if (showFloat)
- {
- initFloatDialog();
- } else
- {
- EasyFloat.dismissAppFloat("readmsg");
- }
- }
-
第6步,实例化 TTS 对象
在 Application 中为TTS指定语言,并实例化语音播放 TTS 对象
- //初始化TTS
- private void initTTS()
- {
- //初始化tts监听对象
- tts = new TextToSpeech(this, onInitListener);
-
- //语音音调调节
- tts.setPitch(1.0f);
-
- //语音音速
- tts.setSpeechRate(0.8f);
- }
-
- /***
- * 播放方法的封装
- */
- public void speakContent(String content)
- {
- if (null == tts)
- {
- initTTS();
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
- {
- tts.speak(content, TextToSpeech.QUEUE_ADD, null, null);
- } else
- {
- tts.speak(content, TextToSpeech.QUEUE_ADD, null);
- }
- }
第7步,播放内容
点击播放按钮,就可以将当前页面的内容分段读出来
- //播放或者停止播放
- if (isChecked)
- {
- String content = SpUtil.get("contents", "");
- String[] contents = content.split(";;;");
-
- //注意太长没法直接播放
- for (String item : contents)
- {
- BaseApplication.getInstance().speakContent(item);
- }
- } else
- {
- BaseApplication.getInstance().stopSpeak();
- }
-
需要注意的是,如果文本太长,没法播放出来,这里是分段的内容从存储文件中取出来,然后分段读出来
经过上面 7步操作,当打开任意一篇微信公众号文章,悬浮框会自动显示,带上耳机,点击播放按钮,文章内容就能自动读出来了。
已经将全部源码上传:https://github.com/xingag/app_spider/tree/master/%E5%BE%AE%E4%BF%A1%E5%85%AC%E4%BC%97%E5%8F%B7%E6%96%87%E7%AB%A0%E8%AF%AD%E9%9F%B3%E6%9C%BA%E5%99%A8%E4%BA%BA。