无障碍服务Talkback和随选朗读适配方案及原理简析

一、无障碍模式功能介绍

某些使用 Android 设备的用户具有不同于他人的无障碍功能需求,比如针对盲人或视力低人群、运动障碍人群等,Android 框架为开发者创建无障碍服务提供了相关功能,开发者可以此提供无障碍功能用以帮助这些有无障碍功能需求的特定人群。

在Android设备中,系统默认提供的无障碍功能点有以下几个:

  1. Talkback - 读出屏幕上的内容
  2. 开关控制 - 利用开关控制设备
  3. 随选朗读 - 听取所选文本
  4. Android TTS
  5. 高对比度文字
  6. 字号更改
  7. 音频均衡
  8. 字幕偏好设置 - 需应用支持,并非所有应用都支持字母偏好设置

显示相关的配置项(5-8)Android系统一般在设置中提供控制选项,无需特殊处理,在此不做赘述,主要记录下语音控制相关的配置项实现功能(1,3,4)。

二、具体应用分析

2.1 Android TTS

​ TTS全称Text-to-Speach,也称作语音输出引擎,把文字转为语音输出,是人机对话的一部分,也是配置各种语音服务的前置条件,没有安装配置语音输出引擎,是无法实现文字转语音功能的。

​ 在源码中的位置 framework/base/core/java/android/speech/,Android TTS 也提供了api给到各种应用使用,以实现各种播报功能。

2.1.1 修改默认引擎

Android中配置的默认引擎是:

public static final String DEFAULT_ENGINE = "com.svox.pico"; 

通过Android 源码分析,可知默认配置的语音引擎是com.svox.pico, SVOX是在Android 很早之前就引入的,Android2.3就已有,有不少不太友好的地方,语音合成效果不佳,支持的语言有限,不支持中文,非免费使用。且在当前分析平台没有预置(如下所示):

75W82B:/ # pm lisy packages | grep -iE svox
75W82B:/ # 

改为使用Google 提供 google tts 作为默认的语音引擎,操作步骤如下:

  1. /frameworks/base/core/java/android/speech/tts/TextToSpeech.java中修改默认配置:

    public static final String DEFAULT_ENGINE = "com.google.android.tts"; 
    
  2. /frameworks/base/packages/SettingsProvider/res/values/defaults.xml添加配置

    <string name="default_tts" translatable="false">com.google.android.tts</string> 
    
  3. /frameworks/base/packages/SettingsProvider/src/com/android/providers/settings/DatabaseHelper.java加载默认数据

    private void loadSecureSettings(SQLiteDatabase db) {  
    ...  
    // 添加  
    loadStringSetting(stmt, Settings.Secure.TTS_DEFAULT_SYNTH,R.string.default_tts);  
    }   
    
  4. 添加Google TTS语音引擎apk

    aml-a311d2-android11中是没有预置此服务的,需要手动配置mk,编译到系统镜像文件中,配置代码如下(无源码有apk预置方式):

    LOCAL_PATH := $(call my-dir)  
      
    include $(CLEAR_VARS)  
    LOCAL_MODULE := StiGoogleTTS  
    LOCAL_MODULE_CLASS := APPS  
    LOCAL_MODULE_TAGS := optional  
      
    APK_FILE_PATH := $(shell cd $(LOCAL_PATH)/$(1) ; \  
              find ./ -maxdepth 1  -name "*.apk" -and -not -name ".*")  
    LOCAL_SRC_FILES := $(APK_FILE_PATH)  
      
    LOCAL_PRIVILEGED_MODULE := true  
    LOCAL_CERTIFICATE := platform  
      
    include $(BUILD_PREBUILT)  
    

完成以上步骤之后,编译完给设备刷机,进入到设备对应的界面就能看到会有配置项了,如下图所示:
image

在未修改系统默认配置,系统未安装任何语音引擎的情况下,点击会出错,提示空指针。根据提示可以在TextToSpeech.java中做修改,防止闪退,在这里我们预置了google TTS ,也能避免出现此crash,进入TTS 配置界面查看
image

默认引擎已经配置为GoogleTTS,如需使用其他语音引擎,例如讯飞语音或者其他,安装对应的引擎apk,按照上述步骤修改默认的,也可在此处手动修改语音引擎。
GoogleTTs.apk 也有平台兼容性,各厂商硬件平台之间的apk不完全兼容,推荐使用硬件对应平台提供的apk,如果找硬件厂商实在找不到,可以使用之前版本稍微较老的兼容性较好的GoogleTTS.apk,下边提供一个下载链接:

2.2 Talkback

​ 包名:com.google.android.marvin.talkback

​ TalkBack 是 Android 的内置屏幕阅读器。开启 TalkBack 后,用户无需查看屏幕即可与 Android 设备互动。视障用户在使用您的应用时可能需要依赖于 TalkBack。

​ 在aml-a311d2-android11平台里是没有内置这款应用的,需要先找到对应的apk,部分厂商会内置(aml-a311d2-android13 `aml-T982-android13有提供),部分不会,提供几个可以用apk,直接给他预置到系统里面,修改对应product 的mk:

LOCAL_PATH := $(call my-dir)  
  
include $(CLEAR_VARS)  
LOCAL_MODULE := StiTalkback  
LOCAL_MODULE_CLASS := APPS  
LOCAL_MODULE_TAGS := optional  
  
APK_FILE_PATH := $(shell cd $(LOCAL_PATH)/$(1) ; \  
          find ./ -maxdepth 1  -name "*.apk" -and -not -name ".*")  
LOCAL_SRC_FILES := $(APK_FILE_PATH)  
  
LOCAL_PRIVILEGED_MODULE := true  
LOCAL_CERTIFICATE := platform  
  
include $(BUILD_PREBUILT) 

完成安装,进入原生设置界面com.android.settings/com.android.settings.Settings$AccessibilitySettingsActivity打开开关界面如下:

image

操作步骤:

  1. 转到无障碍,然后选择 TalkBack。

  2. 在 TalkBack 屏幕的顶部,按开启/关闭即可开启 TalkBack。

    image

  3. 在确认对话框中,选择确定以确认权限。

​ 在Android手机应用中,盲人模式的一大部分实现就是基于talkback,组件做适配来实现的,talkback 是google官方支持实现的AccessibilityService,譬如美团app,抖音app,他们的无障碍功能是通过 开启 Google TalkBack(或第三方屏幕阅读)功能,将用户在屏幕上触摸选中区域的内容朗读出来,使得视障人士可以根据朗读的内容获取自己当前操作区域的信息,来实现的。

三、原理分析

3.1 组成部分

无障碍服务实现需要三个部分:

  1. 辅助操作 APP(Talkback)
  2. 被辅助的 APP (用户使用的各种应用)
  3. 系统服务 AccessibilityManagerService

三者的关系如下图所示:
image
根据上图分析,Talkbackapp之间是没有直接通信的,而是借助SystemServer通过几个aidl 声明的服务来进行通信的,对应的接口文件路径如下:

1、app->SystemServer

\frameworks\base\core\java\android\view\accessibility\IAccessibilityManager.aidl

当 app 产生触摸事件后,会通过该接口发送无障碍事件给 SystemServer 进程的 AccessibilityManagerService

2、SystemServer->Talkback

frameworks\base\core\java\android\accessibilityservice\IAccessibilityServiceClient.aidl

SystemServer 接收到 app 发送的无障碍事件时,会将事件通过该接口传递给Talkback进行处理。

3、Talkback->SystemServer

frameworks\base\core\java\android\accessibilityservice\IAccessibilityServiceConnection.aidl

4、SystemServer->app
\frameworks\base\core\java\android\view\accessibility\IAccessibilityInteractionConnection.aidl

3.2 无障碍事件传递流程

​ 从手指触摸屏幕,到view接收到输出事件,传递流程如下图:
image

3.2.1 分析一下无障碍的事件分发流程,

​ 先对比看一下常规模式下的事件分发流程:
image
再看下无障碍模式下的事件分发:
与常规模式下的触摸事件的分发流程类似,无障碍事件触发事件分发也有类似的三个重要方法:

  1. protected boolean dispatchHoverEvent(MotionEvent event) ,此方法用于进行事件的分发,方法的返回值取决于当前 View 的 onHoverEvent() 方法和子 View 的 dispatchHoverEvent() 方法的。
  2. public boolean onInterceptHoverEvent(MotionEvent event),ViewGroup才有的方法调用,判断是否拦截
  3. public boolean onHoverEvent(MotionEvent event),在 dispatchHoverEvent() 方法中进行调用,处理 hover 事件。

上述三个方法的分发逻辑可参照常规模式下的dispatchTouchEvent()onInterceptTouchEvent()onTouchEvent()的分发流程。对应的事件分发流程可为整理为下图:
image
g)

3.2.2 下面梳理无障碍模式下事件分发函数调用

当接收到屏幕触摸事件时,会调用View.dispatchPointerEvent:

public final boolean dispatchPointerEvent(MotionEvent event) {  
        if (event.isTouchEvent()) {  
            return dispatchTouchEvent(event);  
        } else {  
            // 无障碍,走这一步  
            return dispatchGenericMotionEvent(event);  
        }  
    }  
  
public boolean dispatchGenericMotionEvent(MotionEvent event) {  
        if (mInputEventConsistencyVerifier != null) {  
            mInputEventConsistencyVerifier.onGenericMotionEvent(event, 0);  
        }  
  
        final int source = event.getSource();  
        if ((source & InputDevice.SOURCE_CLASS_POINTER) != 0) {  
            final int action = event.getAction();  
            // 判断事件属于Hover  
            if (action == MotionEvent.ACTION_HOVER_ENTER  
                    || action == MotionEvent.ACTION_HOVER_MOVE  
                    || action == MotionEvent.ACTION_HOVER_EXIT) {  
                if (dispatchHoverEvent(event)) {  
                    return true;  
                }  
            } else if (dispatchGenericPointerEvent(event)) {  
                return true;  
            }  
        }   
        ...  
        return false;  
}

根据无障碍的事件分发流程,可知最终会走到 sendAccessibilityHoverEvent() 方法中,后续根据各种条件还有一系列的方法调用,如下

  1. sendAccessibilityEvent
  2. sendAccessibilityEventUnchecked
  3. onInitializeAccessibilityEvent
  4. dispatchPopulateAccessibilityEvent
  5. onPopulateAccessibilityEvent
  6. onRequestSendAccessibilityEvent

上述六个方法就对应了 各种自定义View 时,适配无障碍模式对应重写的方法,譬如适配Talkback

3.2.3 Talkback 一次事件循环过程

先回顾一下之前看到的无障碍服务三者之间的通信图:

​ 结合上图分析,Talkback发送无障碍事件到app,经过system_process 中转 对应的接口在 IAccessibilityServiceConnection.aidlIAccessibilityInteractionConnection.aidl。最终会调用到App 被触摸 View 的 performAccessibilityAction 方法中,

public boolean performAccessibilityAction(int action, Bundle arguments) {  
      if (mAccessibilityDelegate != null) {  
          return mAccessibilityDelegate.performAccessibilityAction(this, action, arguments);  
      } else {  
          return performAccessibilityActionInternal(action, arguments);  
      }  
    }  

在没有 delegate 的情况下,会继续执行 performAccessibilityActionInternal 方法。

public boolean performAccessibilityActionInternal(int action, Bundle arguments) {  
    ...  
  case AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS: {  
                if (!isAccessibilityFocused()) {  
                    return requestAccessibilityFocus();  
                }  
            } break;  
    ...  
} 

在该方法中,判断到是 ACTION_ACCESSIBILITY_FOCUS 事件,继续执行 requestAccessibilityFocus 方法:

public boolean requestAccessibilityFocus() {  
    AccessibilityManager manager = AccessibilityManager.getInstance(mContext);  
    if (!manager.isEnabled() || !manager.isTouchExplorationEnabled()) {  
        return false;  
    }  
    if ((mViewFlags & VISIBILITY_MASK) != VISIBLE) {  
        return false;  
    }  
    if ((mPrivateFlags2 & PFLAG2_ACCESSIBILITY_FOCUSED) == 0) {  
        mPrivateFlags2 |= PFLAG2_ACCESSIBILITY_FOCUSED;  
        ViewRootImpl viewRootImpl = getViewRootImpl();  
        if (viewRootImpl != null) {  
        // 1、 重绘逻辑  
            viewRootImpl.setAccessibilityFocus(this, null);  
        }  
        invalidate();  
        // 2、通知Talkback  
        sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED);  
        return true;  
    }  
    return false;  
} 
  1. ViewRootImpl 设置自身为 Focus,再调用 invalidate()重绘,后续会触发ViewRootImpl.onPostDraw

    // 触发重绘
    public void onPostDraw(RecordingCanvas canvas) {  
        drawAccessibilityFocusedDrawableIfNeeded(canvas);  
        if (mUseMTRenderer) {  
            for (int i = mWindowCallbacks.size() - 1; i >= 0; i--) {  
                mWindowCallbacks.get(i).onPostDraw(canvas);  
            }  
        }  
    }  
    // 具体绘制边框的了逻辑  
    private void drawAccessibilityFocusedDrawableIfNeeded(Canvas canvas) {  
        final Rect bounds = mAttachInfo.mTmpInvalRect;  
        if (getAccessibilityFocusedRect(bounds)) {  
            final Drawable drawable = getAccessibilityFocusedDrawable();  
            if (drawable != null) {  
                drawable.setBounds(bounds);  
                drawable.draw(canvas);  
            }  
        } else if (mAttachInfo.mAccessibilityFocusDrawable != null) {  
            mAttachInfo.mAccessibilityFocusDrawable.setBounds(0, 0, 0, 0);  
        }  
    } 
    
  2. 发送通过sendAccessibilityEvent发送TYPE_VIEW_ACCESSIBILITY_FOCUSED事件给到Talkback,Talkback接收到该消息后,调用 TTS 播报当前View 的内容

通过以上两个步骤走完,就完成了一次Talkback 播报及界面显示 流程。

四、APP 适配

以 适配 Talkback 为例 包括但不限于做以下调整:

4.1 常规控件文本

​ 给需要播报的 view 添加android:contentDescription,防止无语音播报。对于 TextView / Button会读取text属性,对于 EditText ,TalkBack 会读 hint 属性设置的文字,输入内容后会播报输入后的内容.

4.2 界面元素焦问题导致部分view无法获取 从而无法播报

  1. 此时可启用焦点导航设置 android:nextFocusDown, android:nextFocusLeft, android:nextFocusRight, android:nextFocusUp等属性控制焦点跳转;
  2. 取消不必要的控件播报<View android:impoartantForAccessibility="no"/>

4.3 拦截自定义视图的播报

@Override  
public boolean onRequestSendAccessibilityEvent(View child, AccessibilityEvent event) {  
    return false;  
}  

4.4 拦截界面无关播报

​ 开启 talkback 模式下,在 Activity 或者 Fragment中,进入一个新的页面时,往往会触发系统一些播报,处理方式:

@Override  
public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) {  
    event.getText().add("");  
    return super.dispatchPopulateAccessibilityEvent(event);  
} 

4.5 在上层页面点击仍能选中下层 View

​ 处理方式:将下层的根 View 的 android:importantForAccessibility 属性设置为"noHideDescendants"

4.6 设置自定义View 播报内容

@Override  
public void onPopulateAccessibilityEvent(AccessibilityEvent event) {  
    super.onPopulateAccessibilityEvent(event);  
    final CharSequence text = isChecked() ? "open" : "close";  
    if (text != null) {  
        event.getText().add(text);  
    }  
}  

4.7 自定义 View 播报的控件类型及选中状态

ViewCompat.setAccessibilityDelegate(view, new AccessibilityDelegateCompat(){  
        @Override  
        public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) {  
            super.onInitializeAccessibilityNodeInfo(host, info);  
            info.setClassName(Button.class.getName());  
        }  
 });  

4.8 判断无障碍服务是否开启

​ App中会遇到判断无障碍服务是否开启的情况,Talkback 是无障碍服务的一种实现,无障碍实现不仅仅只有Talkback ,只要是在应用中注册了 对应的服务,并做了相应的配置,都是无障碍服务,这就涉及到自定义是障碍服务的实现,下边是注册的方法:

<!-- 服务标签:用于在无障碍设置界面显示服务名称 -->
<service
    android:name=".TestAccessibilityService"
    android:label="自定义的无障碍服务"
    android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">

    <!-- 过滤器:声明为无障碍服务 -->
    <intent-filter>
        <action android:name="android.accessibilityservice.AccessibilityService"/>
    </intent-filter>
    
    <!-- 元数据配置:指向无障碍服务配置文件 -->
    <meta-data
        android:name="android.accessibilityservice"
        android:resource="@xml/accessibility_service_config"/>
</service>

​ 所以判断设备是否开启无障碍服务,就不能只判断某一个注册了 AccessibilityService 的进程是否在运行,还有些软件如爱奇异App也使用了AccessibilityService,然而此类App不是为了提供无障碍服务,而是使用AccessibilityService以实现诸如自动安装等功能,所以在进行无障碍服务是否开启的判断中要排除此类App使用AccessibilityService的方式,以下是对应的判断逻辑:

public static boolean isAccessibilityEnabled(Context context) throws RuntimeException{  
        if (context == null) {  
            return false;  
        }  
        // 检查AccessibilityService是否开启  
        AccessibilityManager am = (AccessibilityManager) context.getSystemService(android.content.Context.ACCESSIBILITY_SERVICE);  
        boolean isAccessibilityEnabled_flag = am.isEnabled();  
        boolean isExploreByTouchEnabled_flag = false;  
        // 检查无障碍服务是否以语音播报的方式开启  
        isExploreByTouchEnabled_flag = isScreenReaderActive(context);  
        return (isAccessibilityEnabled_flag && isExploreByTouchEnabled_flag);  
    }  
    private final static String SCREEN_READER_INTENT_ACTION = "android.accessibilityservice.AccessibilityService";  
    private final static String SCREEN_READER_INTENT_CATEGORY = "android.accessibilityservice.category.FEEDBACK_SPOKEN";  
  
    private static boolean isScreenReaderActive(Context context) {  
  
        // 通过Intent方式判断是否存在以语音播报方式提供服务的Service,还需要判断开启状态  
        Intent screenReaderIntent = new Intent(SCREEN_READER_INTENT_ACTION);  
        screenReaderIntent.addCategory(SCREEN_READER_INTENT_CATEGORY);  
        List<ResolveInfo> screenReaders = context.getPackageManager().queryIntentServices(screenReaderIntent, 0);  
        // 如果没有,返回false  
        if (screenReaders == null || screenReaders.size() <= 0) {  
            return false;  
        }  
        boolean hasActiveScreenReader = false;  
        if (Build.VERSION.SDK_INT <= 15) {  
            ContentResolver cr = context.getContentResolver();  
            Cursor cursor = null;  
            int status = 0;  
            for (ResolveInfo screenReader : screenReaders) {  
                cursor = cr.query(Uri.parse("content://" + screenReader.serviceInfo.packageName  
                        + ".providers.StatusProvider"), null, null, null, null);  
                if (cursor != null && cursor.moveToFirst()) {  
                    status = cursor.getInt(0);  
                    cursor.close();  
                    // 状态1为开启状态,直接返回true即可  
                    if (status == 1) {  
                        return true;  
                    }  
                }  
            }  
        } else if (Build.VERSION.SDK_INT >= 26) {  
            // 高版本可以直接判断服务是否处于开启状态  
            for (ResolveInfo screenReader : screenReaders) {  
                hasActiveScreenReader |= isAccessibilitySettingsOn(context, screenReader.serviceInfo.packageName + "/" + screenReader.serviceInfo.name);  
            }  
        } else {  
            // 判断正在运行的Service里有没有上述存在的Service  
            List<String> runningServices = new ArrayList<String>();  
  
  
            android.app.ActivityManager manager = (android.app.ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);  
            for (android.app.ActivityManager.RunningServiceInfo service : manager.getRunningServices(Integer.MAX_VALUE)) {  
                runningServices.add(service.service.getPackageName());  
            }  
            for (ResolveInfo screenReader : screenReaders) {  
                if (runningServices.contains(screenReader.serviceInfo.packageName)) {  
                    hasActiveScreenReader |= true;  
                }  
            }  
        }  
       return hasActiveScreenReader;  
    }  
  
    // To check if service is enabled  
    private static boolean isAccessibilitySettingsOn(Context context, String service) {  
        TextUtils.SimpleStringSplitter mStringColonSplitter = new TextUtils.SimpleStringSplitter(':');  
        String settingValue = Settings.Secure.getString(  
                context.getApplicationContext().getContentResolver(),  
                Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES);  
        if (settingValue != null) {  
            mStringColonSplitter.setString(settingValue);  
            while (mStringColonSplitter.hasNext()) {  
                String accessibilityService = mStringColonSplitter.next();  
                if (accessibilityService.equalsIgnoreCase(service)) {  
                    return true;  
                }  
            }  
        }  
        return false;  
} 

以上是无障碍功能的小结。

posted @ 2025-06-18 20:05  阿丟啊  阅读(750)  评论(0)    收藏  举报