1. 程式人生 > >Android輸入法擴充套件之外接鍵盤中文輸入

Android輸入法擴充套件之外接鍵盤中文輸入

    大家想不想要這樣一臺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);
	}
}

完美解決方案

上面的解決方案是直接在輸入法程式內部修改達到實現外接鍵盤輸入中文,屬於應用程範疇,但是仍有一些問題,而這些問題在程式端是沒法解決的。那該怎麼完美解決呢,Andorid後來的版本已經解決了這個,是如何解決的?

即所有的按鍵事件先發送給程式,然後程式端的程式碼會先將key傳送給輸入法,即讓輸入法有一個翻譯轉換過程的機會,然後輸入法再將轉化過的key或者字元傳送回程序,也就是說key事件繞了一圈,最後再讓程式端處理。

附錄

        最近工作比較忙,程式碼還沒有整理好,等整理好後,我會將原始碼發出來,大家可以一起學習。

/********************************

* 本文來自部落格  “愛踢門”

******************************************/