大家想不想要这样一台Android Surface平板,看着就过瘾吧。
我们知道,android目前的输入都是通过软键盘实现的,用外接键盘的少,这个在手机上是可以理解的。当手机接上外接键盘后,整体会显得头重脚轻,并且用键盘输入时,人离手机的距离就远了,自然不太适合看清手机上的内容。那在平板上呢?如果平板只是平时用来浏览看视频,不进行大量输入,自然也用不上外接键盘。那究竟什么时候需要用到外接键盘呢?本人觉得首先要满足如下两个条件。
1) 平板和外接键盘完美融合,组合后很像笔记本使用模式。类似上面Android Surface的机器,平板和键盘通过磁性自动粘合,变身笔记本模式
2) Android用在类办公等需要快速输入场景,比如写文章,长时间聊qq等。其实linux一直以来没法进入桌面系统的关键原因是window在这方面太优秀,它垄断了用户的办公习惯,即用Microsoft office系列软件办公。但是现在类linux,尤其Android在这边已经有了很大进步,一方面,ubuntu帮组linux积累了一部分用户,比如libre office体验好多了。同时据说微软正在为Android开发Microsoft office的响应产品,这个是利好消息。
从上面看来,其实市面上已经有满足上面两个条件的机器了,比如联想的A10
它是一台超级本, 但它支持翻转,当翻转过来就是平板。
那为啥这种Android超极本就不够火呢?当然有很多原因啊,比如平板本身需求量小,Android本身就不适合办公,当然肯定也有另外一个小原因,它这个物理键盘竟然不能中文输入。因此,Android平板要进入办公领域并流行,需要实现类似PC端中文输入的体验。
本文说到的外接键盘中文输入,重在中文两字。事实上,Android本身是支持外接键盘的,但是只能够实现英文输入。其实,我们在前几篇文章已经说到了输入法,也已经分析到,Android要想输入中文,必须通过输入法。那为啥Android的中文输入法不能像PC那样直接通过外接键盘输入呢?下面一一分析。
Android没法通过外接键盘中文输入原因
输入法和外接键盘不能共存
Android系统里,当有外接键盘时,输入法就会消失,这样自然没法通过输入法输入中文。这个是由Configuration的keyboard配置项决定的。正常情况下,Configuration的keyboard值是nokeys,而当系统检测到外接键盘(蓝牙键盘等等)插入时,就会更新系统的Configuration,并将其中的keyboard置为非nokeys(比如Configuration.KEYBOARD_QWERTY),然后系统会将新的Configuration通知给所有程序,包括输入法。当输入法程序检测到新的Configuration时,它会执行更新操作,然后发现已经有外接设备就会隐藏自己,这样输入法就不见了。
具体逻辑如下:
//系统端 :WindowManagerService.java boolean computeScreenConfigurationLocked(Configuration config, boolean forceRotate) { final InputDevice[] devices = mInputManager.getInputDevices(); final int len = devices.length; for (int i = 0; i < len; i++) { InputDevice device = devices[i]; if (!device.isVirtual()) { final int sources = device.getSources(); final int presenceFlag = device.isExternal() ? WindowManagerPolicy.PRESENCE_EXTERNAL : WindowManagerPolicy.PRESENCE_INTERNAL; if (device.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) { //检测到外接键盘 config.keyboard = Configuration.KEYBOARD_QWERTY; keyboardPresence |= presenceFlag; } } } // Determine whether a hard keyboard is available and enabled. boolean hardKeyboardAvailable = config.keyboard != Configuration.KEYBOARD_NOKEYS; if (hardKeyboardAvailable != mHardKeyboardAvailable) { mHardKeyboardAvailable = hardKeyboardAvailable; mHardKeyboardEnabled = hardKeyboardAvailable; mH.removeMessages(H.REPORT_HARD_KEYBOARD_STATUS_CHANGE); mH.sendEmptyMessage(H.REPORT_HARD_KEYBOARD_STATUS_CHANGE); } if (!mHardKeyboardEnabled) { config.keyboard = Configuration.KEYBOARD_NOKEYS; } } return true; } //输入法端: InputMethodService.java @Override public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); if (visible) { if (showingInput) { // onShowInputRequested就会影响输入法的显示 //当有外接键盘时,它会返回false if (onShowInputRequested(showFlags, true)) { showWindow(true); } else { doHideWindow(); } } // onEvaluateInputViewShown也会影响输入法的显示 //当有外接键盘时,它会返回false boolean showing = onEvaluateInputViewShown(); mImm.setImeWindowStatus(mToken, IME_ACTIVE | (showing ? IME_VISIBLE : 0), mBackDisposition); } } public boolean onEvaluateInputViewShown() { Configuration config = getResources().getConfiguration(); //检测Configuration是否标示了有外接键盘 return config.keyboard == Configuration.KEYBOARD_NOKEYS || config.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_YES; } public boolean onShowInputRequested(int flags, boolean configChange) { if (!onEvaluateInputViewShown()) { return false; } if ((flags&InputMethod.SHOW_EXPLICIT) == 0) { Configuration config = getResources().getConfiguration(); //检测Configuration是否标示了有外接键盘 if (config.keyboard != Configuration.KEYBOARD_NOKEYS) { return false; } } if ((flags&InputMethod.SHOW_FORCED) != 0) { mShowInputForced = true; } return true; }
输入法没法获得按键事件
我们知道,如果要想输入法通过外接键盘输出中文,它肯定需要从外接键盘读取到英文输入。而在Android系统中,按键等key事件只发送给焦点程序,但是输入法本身没法获得焦点,因此它自然就没法读取到外接键盘的输入。
问题的解决
让输入法和外接键盘共存
从上面的分析可知,输入法和外接键盘没法共存的根本原因是,输入法会读取configuration里的键盘属性值。解决这个问题有两个方法:
1) 修改用到Configuration的相关函数,比如onEvaluateInputViewShown ,onShowInputRequested函数的实现
这个方法看起来可行,但是不行。因为很多地方可能用到了这个Configuration,修改量比较大,且很多函数并非protected或者public,子类是没法直接修改的。
2) 修改输入法的Configuration的值
这个方法可行,从源头上解决了这个问题,这样InputMethodService认为系统没有外接键盘,自然就不会隐藏输入法了。
方法2具体实现如下:
在输入法初始化和更新Configuration的点主动修改输入法的Configuration。
public class RemoteInputMethod extends InputMethodService { @Override public void onCreate() { super.onCreate(); updateResources(); } @Override public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); updateResources(); } public void updateResources() { Configuration config = new Configuration(getResources().getConfiguration()); //修改Configuration,让输入法认为系统中没有外接键盘 config.keyboard = Configuration.KEYBOARD_NOKEYS; getResources().updateConfiguration(config, getResources().getDisplayMetrics()); }}
让输入法获取外接键盘输入
输入法实现输入有两部分,一是获取按键事件,二是获取输入目标
获取按键事件
上面已经提到过,输入法window是没法获取外接键盘事件的,怎么办?很好办,让输入法service创建另外一个普通的window(本文称作bridge window),并将这个window标示为可接受key事件的window,当它是最top的可接受key事件的window时, 它就可以获得焦点并获得外接键盘的输入。这样,它作为中间桥梁就能将外接键盘事件传给输入法 (同一程序里,很好做的),输入法然后进行翻译,比如拼音转为中文。
获取并更新输入目标
输入法的输入目标是textView的通信接口InputConnection。它是在程序获得焦点时候或焦点程序中的焦点view发生变化的时候,焦点程序传递给输入法的。
所以,问题来了?一旦上面的bridge window获得焦点后,输入法的输入目标就跟着更新了,变成了bridge window的view的InputConnection。这样即使输入法完成了英文到中文的转换,最后也只能将中文发送给bridge window,并不能发送给用户想输入的程序。怎么解?还好Android系统有一个特殊window flag-----WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM,当一个window设置了这个flag, 它成为焦点时,输入法并不会将输入目标切换为当前焦点window的InputConnection,而是仍旧保持原来的InputConnection。这为我们带来了希望,也就是说,我们只需将我们的bridge window添加这个flag即可,事实上确实如此。
但是还存在一个问题。我们知道InputConnection是对应textView的一个通信接口,当用户改变输入view时,输入法中的InputConnection是需要修改的,但是现在由于目标程序已经不是焦点程序了,当用户触摸目标程序其他textView导致输入view改变时,系统并不会通知输入法去更新InputConnection,这样一来,输入法的中文始终只能传递给一个textView了。又怎么解呢?灵光一动,继续解。当用户触摸时,我们可以让bridge window暂时失去焦点,这样目标程序就重新获取了焦点,然后输入view切换时,输入法就能得到通知,也就是能重新获取到新的textView的InputConnection。然后,bridge window重新获取焦点,也就是很短时间后它继续可以接受外接键盘的输入了。
这个方案的重点在bridge window的实现:实现的重点有两个:
1) 添加WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM flag
2) 监听OUT_SIDE事件,这样,当用户单击目标程序,切换焦点view时,bridge window能够提前获知,然后释放焦点,
让目标程序成为焦点,然后完成焦点view的切换,进而完成输入法中的输入目标InputConnection的更新。
public class BridgeWindow extends Dialog { private static final boolean DEBUG = false; private static final String TAG = "MDialog"; private static final int flagsNask = WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; private static final int flags = WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH; private static final int flags_nofocus = WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; private Window mWindow = null; private Handler mHandler = new Handler(); private MInputMethod mAttachedInputMethod = null; public BridgeWindow (Context context) { super(context); // TODO Auto-generated constructor stub init(); } public void setAttachedInputMethod(MInputMethod inputMethod) { mAttachedInputMethod = inputMethod; } View mRootView = null; public void setContentView(View view) { super.setContentView(view); mRootView = view; } private void init() { // TODO Auto-generated method stub requestWindowFeature(Window.FEATURE_NO_TITLE); setTitle("HardInputMethod"); mWindow = this.getWindow(); LayoutParams lp = mWindow.getAttributes(); lp.gravity = Gravity.LEFT|Gravity.TOP; lp.x = 0; lp.y = 0; mWindow.setType(WindowManager.LayoutParams.TYPE_PHONE); //初始化window的flag mWindow.setFlags(flags, flagsNask); } @Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() == MotionEvent.ACTION_OUTSIDE) { //检测到用户触摸了bridge window外的区域,那么焦点view可能要发生 //变化了,输入法的InputConnection需要更新了,所以在此暂时取消自己 //的focus if (DEBUG) Log.d(TAG, "release focus"); releaseFocus(); } return super.onTouchEvent(event); } @Override public boolean onKeyDown(int keyCode, KeyEvent event) { if (DEBUG) Log.d(TAG, "onKeyDown" + keyCode); //将事件传递给输入法 mAttachedInputMethod.onKeyDown(keyCode, event); return super.onKeyDown(keyCode, event); } protected void releaseFocus() { // TODO Auto-generated method stub //将自己配置成不可获取焦点来让自己失去焦点 mWindow.setFlags(flags_nofocus, flagsNask); mHandler.removeCallbacks(mFocusRunnable); //1s钟后,让自己重新获取焦点 mHandler.postDelayed(mFocusRunnable, 1000); } Runnable mFocusRunnable = new Runnable() { @Override public void run() { // TODO Auto-generated method stub mWindow.setFlags(flags, flagsNask); } }; Point mDownPosition = new Point(); public void onDown(int x, int y) { // TODO Auto-generated method stub int[] loc = new int[2]; mRootView.getLocationOnScreen(loc); mDownPosition.x = loc[0]; mDownPosition.y = loc[1] - 50; if (DEBUG) Log.d(TAG, "on down position x:" + loc[0] + " y:" + loc[1]); } public void onMove(int offsetX, int offsetY) { // TODO Auto-generated method stub updatePositioin(mDownPosition.x + offsetX, mDownPosition.y + offsetY); } private void updatePositioin(int x, int y) { LayoutParams lp = mWindow.getAttributes(); lp.x = x; lp.y = y; mWindow.setAttributes(lp); }}
完美解决方案
上面的解决方案是直接在输入法程序内部修改达到实现外接键盘输入中文,属于应用程范畴,但是仍有一些问题,而这些问题在程序端是没法解决的。那该怎么完美解决的,只有修改framework(修改WindowManagerService里的findFocusedWindowLocked,
inputMethodClientHasFocus函数), 让输入法也可以获得焦点,这样它自己就能获取按键输入,这才算完美解决。
附录
最近工作比较忙,代码还没有整理好,等整理好后,我会将源码发出来,大家可以一起学习。