输入法在整个Android用户体验生态中扮演了及其重要的角色,每个用户都不可避免的会与输入法打交道。但是网上关于Activity的技术文章多如牛毛,对于输入法及其框架的深度解析却很少,所以一直想尝试完成一个输入法框架的专题,于是整个输入法框架深度解析系列应运而生。
概述
首先我们了解输入法框架(InputMethodFramework, 下简称IMF)整体的UML图,基于Android10,不同Android版本之间会有少许差异,不过不影响整体结构。
从UML图可以看出IMF涉及到三个主要部分:
- InputMethodManager(下简称IMM)是整个输入法框架的核心,运行于客户端进程,客户端可以使用IMM对输入焦点和输入法状态进行控制,但是同一时间只有一个客户端处于激活状态。
- InputMethodService(下简称IMS),是输入法IME(InputMethodEditor)的具体实现,运行于输入法进程。同一时间只有一个IME运行。
- InputMethodManagerService(下简称IMMS)运行于系统进程,是一个系统级服务,是IMM和IMS沟通的桥梁。
整个I MF是就是围绕IMM,IMS和IMMS三个核心部分进行工作的。以下是对UML图中出现的重要接口的简单介绍:
- IInputMethod: 这个是输入法进程向系统进程暴露的接口。用于系统进程和输入法进程通信。
- IInputMethodSession: 这个是输入法进程向外暴露的第二个接口,用于客户端进程和输入法进程通信。
- IInputContext:这个是客户端进程向输入法进程暴露的接口。用于输入法进程和客户端进程通信。
- IInputMethodClient:这个是客户端进程向系统进程暴露的接口。用于系统进程和客户端进程通信。
- IInputMethodManager: 这个是系统进程向客户端进程暴露的接口。
以上接口都属于aidl接口,IMF围绕以上五个接口来实现整体的交互逻辑。 需要说明的是Android版本在后期的迭代中对前三个接口又做了进一步封装。
以IInputMethod为例,谷歌又定义了一个新的接口InputMethod,包含了和IInputMethod相同的接口。具体的实现逻辑从IInputMethod的实现类移动到InputMethod实现类,大概是为了尽量屏蔽aidl接口对于上层实现的影响。
Note:输入法进程代表IMS所在的进程,客户端进程代表IMM所在的进程,这两者可以是同一个进程,但是即使是同一个进程仍然需要通过aidl的方式进行通信。
下图是对以上接口所涉及到的部分流程进行举例
- 客户端点击输入框,IMM通过IInputMethodManager接口向IMMS请求显示输入法,IMMS收到请求通过IInputMethod接口向IMS进程转发请求,使输入法展现。
- IMM进程当光标发生位置发生改变时,IMM通过IInputMethodSession接口,通知IMS光标位置发生变化。
- IMS进程通过IInputContext将字符上屏到客户端的编辑框。
后续会围绕上述三个部分以及关键流程进行逐步分析,以点到面的方式完成对整个IMF的理解。 初识IMMS 分析IMF我们从IMMS开始,因为它相对于IMM和IMS,较简单。同时IMMS也是整个IMF的开始的地方。 IMMS是android的一个系统级服务,运行于system_process进程中。主要作用是管理输入法,绑定输入法,管理客户端以及和其他系统级服务交互。IMMS的创建同其他系统服务,这里不再过多介绍。 IMMS在初始化时会创建一个List和Map,用来管理输入法。
static void queryInputMethodServicesInternal(Context context, @UserIdInt int userId, ArrayMap> additionalSubtypeMap, ArrayMap methodMap, ArrayList methodList) { methodList.clear(); methodMap.clear(); // 1. 获取所有包含action为android.view.InputMethod的IntentFilter的Service final List services = context.getPackageManager().queryIntentServicesAsUser( new Intent(InputMethod.SERVICE_INTERFACE), PackageManager.GET_META_DATA | PackageManager.MATCH_DISABLED_UNTIL_USED_COMPONENTS, userId); methodList.ensureCapacity(services.size()); methodMap.ensureCapacity(services.size()); // 2. 遍历获取的Service列表 for (int i = 0; i < services.size(); ++i) { ResolveInfo ri = services.get(i); ServiceInfo si = ri.serviceInfo; final String imeId = InputMethodInfo.computeId(ri); // 3. 对每一个Service检查是否有android.permission.BIND_INPUT_METHOD权限 if (!android.Manifest.permission.BIND_INPUT_METHOD.equals(si.permission)) { Slog.w(TAG, "Skipping input method " + imeId + ": it does not require the permission " + android.Manifest.permission.BIND_INPUT_METHOD); continue; } if (DEBUG) Slog.d(TAG, "Checking " + imeId); try { // 4. 创建InputMethodInfo对象,并放入List和Map中 final InputMethodInfo imi = new InputMethodInfo(context, ri, additionalSubtypeMap.get(imeId)); if (imi.isVrOnly()) { continue; // Skip VR-only IME, which isn't supported for now. } methodList.add(imi); methodMap.put(imi.getId(), imi); if (DEBUG) { Slog.d(TAG, "Found an input method " + imi); } } catch (Exception e) { Slog.wtf(TAG, "Unable to load input method " + imeId, e); } } }
所以一个组件是不是输入法是可以通过以下条件判断:
- 是否是Service
- Service是否包含action为android.view.InputMethod的IntentFilter
- Service是否拥有android.permission.BIND_INPUT_METHOD权限
若条件都满足,会创建一个InputMethodInfo对象,每一个输入法Service(即IMS)都对应一个InputMethodInfo对象。InputMethodInfo主要存储了以下信息:
-
输入法的id
id是IMS在IMMS中的唯一标识,也是输入法Map的key。如果一个输入法应用包名是com.demo.ime,对应IMS的全路径是com.demo.ime.DemonIME,那么它的id为com.demo.ime/.DemoIME
-
输入法支持的subType
IMS在Manifest声明的同时也必须提供meta-data,包含了一个XML。XML里声明了输入法支持的subType,允许不支持subType或者支持多个subType。
-
输入法设置的Activity组件名
XML里也可以声明输入法的设置Actvity,允许用户从系统设置界面直接进入输入法设置 界面。
subType指的是输入法向系统声明所支持的语言和类型(仅仅是声明,和实际支持的语言无关)。于是就会出现这样一种场景,当系统设置了多个语言,声明的subType包含了两个或以上的系统语言时,就会出现多个选项。
同一个输入法,会显示出多个选项,这无疑会对用户产生干扰。所以要么在xml不声明subType,要么在其中一个subType中加入
android:overridesImplicitlyEnabledSubtype="true"
这个属性默认是false,显示设置成true后,当前的subType会覆盖其他的subType, 保证最后出现在列表中的只有一个选项。
Note:如果开发系统预装输入法,建议声明subType,否则首次开机启动可能会出现找不到输入法的情况。
绑定输入法
首先要明确输入法(IMS)实际是一个Service,绑定输入法实际就是IMMS绑定IMS的过程,就需要遵循Service的绑定流程。
注意:Service被非正常方式解绑才会回调onServiceDisconnected,unbindService不会回调该方法。
@Overridepublic void onServiceConnected(ComponentName name, IBinder service) { synchronized (mMethodMap) { if (mCurIntent != null && name.equals(mCurIntent.getComponent())) { // 1. 获取输入法进程的一个远程代理对象 mCurMethod = IInputMethod.Stub.asInterface(service); if (mCurToken == null) { Slog.w(TAG, "Service connected without a token!"); unbindCurrentMethodLocked(); return; } if (DEBUG) Slog.v(TAG, "Initiating attach with token: " + mCurToken); // Dispatch display id for InputMethodService to update context display. // 2. 发送初始化消息 executeOrSendMessage(mCurMethod, mCaller.obtainMessageIOO( MSG_INITIALIZE_IME, mCurTokenDisplayId, mCurMethod, mCurToken)); if (mCurClient != null) { // 3. 发送消息获取Session的消息 clearClientSessionLocked(mCurClient); requestClientSessionLocked(mCurClient); } } }} @Overridepublic void onServiceDisconnected(ComponentName name) { // Note that mContext.unbindService(this) does not trigger this. synchronized (mMethodMap) { if (DEBUG) Slog.v(TAG, "Service disconnected: " + name + " mCurIntent=" + mCurIntent); if (mCurMethod != null && mCurIntent != null && name.equals(mCurIntent.getComponent())) { // 1. mCurMethod置为null clearCurMethodLocked(); // 2. 更新上次绑定时间 mLastBindTime = SystemClock.uptimeMillis(); mShowRequested = mInputShown; mInputShown = false; // 3.解除客户端和输入法之间的关联 unbindCurrentClientLocked(InputMethodClient.UNBIND_REASON_DISCONNECT_IME); } }} static class SessionState { final ClientState client; final IInputMethod method; IInputMethodSession session; InputChannel channel;}
在onServiceConnected中主要做了三件事
- 通过回调的参数IBinder对象,获得IInputMethod接口的实现并赋值给mCurMethod。IMMS通过它去调用IMS的相关接口。
- 通过消息调用IMS的初始化方法,后续流程大多在IMS里,这里先不作详细展开。
- 通过一系列复杂调用和回调,从输入法进程获得IInputMethodSession接口的实现,同时创建一个SessionState的实例保存这个对象。
SessionState表示当前输入法的会话状态,保存了输入法向外暴露的两个接口,和ClientState互相引用。在onServiceDisconnected中则包含了以下逻辑:
- 将mCurMethod置为null。
- 更新mLastBindTime为当前时间,这个变量记录了上次绑定的时间。
- 解除客户端和输入法之间的联系。
在未绑定输入法时, 触发绑定流程一般有如下两种场景:焦点变化,点击编辑框。当焦点变化时(包括窗口焦点变化和View焦点变化),当前应用会通过IMM跨进程调用IMMS的startInputOrWindowGainFocus方法,最终会走到startInputUncheckdLocked方法。
InputBindResult startInputUncheckedLocked(@NonNull ClientState cs, IInputContext inputContext, @MissingMethodFlags int missingMethods, @NonNull EditorInfo attribute ......) { ...... // 1. 这里会检查默认输入法是否有变化 // Check if the input method is changing. // We expect the caller has already verified that the client is allowed to access this // display ID. if (mCurId != null && mCurId.equals(mCurMethodId) && displayIdToShowIme == mCurTokenDisplayId) { ....... // 2. 如果没有变化,这里会return,不会走下面逻辑 } InputMethodInfo info = mMethodMap.get(mCurMethodId); // 3. 这里先解绑当前输入法 unbindCurrentMethodLocked(); mCurIntent = new Intent(InputMethod.SERVICE_INTERFACE); mCurIntent.setComponent(info.getComponent()); mCurIntent.putExtra(Intent.EXTRA_CLIENT_LABEL, com.android.internal.R.string.input_method_binding_label); mCurIntent.putExtra(Intent.EXTRA_CLIENT_INTENT, PendingIntent.getActivity( mContext, 0, new Intent(Settings.ACTION_INPUT_METHOD_SETTINGS), 0)); // 4. 绑定新的输入法 if (bindCurrentInputMethodServiceLocked(mCurIntent, this, IME_CONNECTION_BIND_FLAGS)) { // 5. 记录一个绑定时间,并更新mHaveConnection标记位 mLastBindTime = SystemClock.uptimeMillis(); mHaveConnection = true; ...... return new InputBindResult( InputBindResult.ResultCode.SUCCESS_WAITING_IME_BINDING, null, null, mCurId, mCurSeq, null); } mCurIntent = null; Slog.w(TAG, "Failure connecting to input method service: " + mCurIntent); return InputBindResult.IME_NOT_CONNECTED;}
和绑定流程相关的逻辑如下:
- 如果默认输入法有变化或者默认输入法是未绑定(mCurMethod为null),会向下执行,否则直接return。
- 解绑并重新输入法。
- 赋值上次绑定时间mLastBindTime为当前时间,赋值标志位mHaveConnection为true。
如果编辑框已经获得焦点(未获得焦点会走上面的流程),点击编辑框,最终会调用到showCurrentInputLocked方法。
boolean showCurrentInputLocked(int flags, ResultReceiver resultReceiver) { ...... boolean res = false; if (mCurMethod != null) { // 1. 如果已经绑定则走之后显示输入法的流程 ....... } else if (mHaveConnection && SystemClock.uptimeMillis() >= (mLastBindTime+TIME_TO_RECONNECT)) { // 2. 如果没有绑定且标志位为true,同时距离上次绑定已经超过设定的阈值,通常为三秒,则解绑再绑定。 Slog.w(TAG, "Force disconnect/connect to the IME in showCurrentInputLocked()"); mContext.unbindService(this); bindCurrentInputMethodServiceLocked(mCurIntent, this, IME_CONNECTION_BIND_FLAGS); } else { if (DEBUG) { Slog.d(TAG, "Can't show input: connection = " + mHaveConnection + ", time = " + ((mLastBindTime+TIME_TO_RECONNECT) - SystemClock.uptimeMillis())); } } return res;}
当同时满足以下条件时会走进绑定流程
- 当前没有绑定输入法(mCurMethod没有被赋值)
- mHaveConnection是true
- 距离上次绑定已超过阈值3秒
所以当输入法绑定时间超过了3秒,也就是在3秒内没有回调onServicConnected的场景下会走重新绑定的流程。
IMMS的BUG
那么问题来了,我们曾多次接到线上用户反馈,键盘调不起来。通过抓取线上用户的Log,发现了某些系统Log在短时间内大量打印,类似下图。
根据之前我们的分析,上述Log表明IMMS短时间内多次绑定和解绑输入法,当然键盘就一直调不起来了。
分析具体原因,于是就有了下面的复现路径:
- 进程意外死亡会触发onServiceDisconnected,mCurMethod设置为null,同时更新一次mLastBindTime。
- 接着会调用IMMS的startInputOrWindowGainFocus,绑定输入法和再次更新mLastBindTime。
- 点击编辑框,触发showCurrentInputLocked
- 如果已经绑定成功则执行后续键盘显示流程并返回,不会执行下面的流程
- 如果未绑定成功但是时间未超过3s,则打印一行log
- 如果时间已超过3s仍未绑定成功,则先解绑, IMS执行onDestroy流程,再绑定,重新执行onCreate→ onBind流程,但此时mLastBindTime不会再更新。
如果首次绑定时间超过3秒(进程启动时间+IMS绑定时间),那么重复点击就会重复执行步骤3 ~ 6,如果在两次点击之间无法绑定成功,而又因为mLastBindTime不再更新的缘故,每次都会判断超过3s,不断重新走解绑和绑定流程。
这显然是一个IMMS的BUG且存在已久,对于应用开发来说,尽可能把首次绑定时间缩短到3秒以内则可以有效避免这个问题。
管理客户端进程
IMM是一个单例(不考虑在多屏情况下),在进程中被创建的时候会在IMMS内部生成一个对应的ClientState对象,放在一个Map中缓存起来。当进程死亡后,会从Map中移除。
@Overridepublic void addClient(IInputMethodClient client, IInputContext inputContext, int selfReportedDisplayId) { ...... synchronized (mMethodMap) { ...... final ClientDeathRecipient deathRecipient = new ClientDeathRecipient(this, client); try { // 1. 注册进程死亡监听,方便进程死亡后从map中移除 client.asBinder().linkToDeath(deathRecipient, 0); } catch (RemoteException e) { throw new IllegalStateException(e); } // 2. 创建ClientState对象,把参数client和inputContext缓存起来 mClients.put(client.asBinder(), new ClientState(client, inputContext, callerUid, callerPid, selfReportedDisplayId, deathRecipient)); }}
ClientState负责管理当前客户端的状态,包含了以下成员变量
- client: IInputMethodClient接口的实现,作为参数由IMM传递给IMMS,IMMS可以通过它与对应进程的IMM通信。
- inputContext: IInputContext接口的实现,默认的InputConnection,由IMM传过来的,实际我们在使用的不是这个对象。
- binding: 封装了当前客户端的uid,pid等,最后会传给IMS,但是很少使用。
- curSession: 每一个输入法被系统绑定后会生成一个SessionState,这里缓存的就是当前默认输入法的SessionState。最终curSession会被传递给客户端进程,用于IMM调用IMS的相关接口。
static final class ClientState { // 1. IMMS通过它与IMM通信 final IInputMethodClient client; // 2. 默认的inputConnection,实际在使用的时候用的并不是这个对象。 final IInputContext inputContext; final int uid; final int pid; final int selfReportedDisplayId; // 3. 这个会传给IMS,只是对uid/pid/inputContext做了一层封装,用到的时候不多 final InputBinding binding; final ClientDeathRecipient clientDeathRecipient; boolean sessionRequested; // Determines if IMEs should be pre-rendered. // DebugFlag can be flipped anytime. This flag is kept per-client to maintain behavior // through the life of the current client. boolean shouldPreRenderIme; // 4. 表示当前默认输入法的会话状态 SessionState curSession;}
最后
IMMS是IMS和IMM沟通的桥梁,绑定输入法和管理客户端都会与IMS和IMMS有大量的交互,以上只是单纯从IMMS的角度去分析,后续在分析IMS和IMM的时候会结合到IMMS,对整个流程进行详细的拆解,以点到面去完成对IMF的深入理解。