前一阵子,应泰国客户需求,需要在Android TV系统定制一个多语言输入法,至少支持中、英、泰三种语言。拿到这个任务,对于至今还是小白的我来说,当然先去google一下有没有大神专门做过符合要求的输入法应用。很遗憾,网上移动终端倒是有不少满足需求的输入法,而且做得还满酷炫,当时搜到的最全面的最接近需求(包含泰语这种名不见经传的小语种)的输入法应用当属Go Keyboard,后来发现我的HTC one手机自带的HTC Sense Input输入法也满足要求,而且还很纯净,系统原生的无广告,符合Material Design风格,但是能在TV上用的还真没找到(口碑不错的搜狗输入法倒是也做了TV版,可惜只支持中英文切换)。
对于程序员来说,开发是你的本职,没有开发过的东西,对我们恰好是机遇,况且使用第三方输入法,毕竟控制权不在自己手里,用户使用出了问题,也修复不了bug,源码拿不到啊!不过,这次的需求是定制系统输入法,不是做软键盘,在时间精力有限情况下,让一个菜鸟短时间内开发出来不太现实,那怎么办呢,这是后话。我们先看看需求出现的客观原因吧!
分析原因,Android TV起步较晚是一方面,操作方式跟手机不一样是根本原因,不同于手机touch,TV是通过遥控控制焦点来执行用户操作。所以,凡是不支持焦点控制的移动端应用,在TV上要么用不了,要么用户体验差(目前TV BOX支持鼠标、键盘操作),更有存在因分辨率引起的显示问题。当然有需求就会有市场,为什么没人在TV输入法模块投入精力去开发呢?原因很简单,遥控输入法真心不好用,至于原因,想想操作方式,再拿个遥控实际体验一下就知道了,输个密码都很艰难,谁还用?况且有人开发了远程输入法,即手机跟盒子在同一个网关环境下,通过手机输入、TV负责显示输出的方式,相当于手机作为遥控使,很方便。还有更方便的,直接把遥控做成键盘,使用硬键盘输入模式。
既然这样,我后面也就不用写下去了,因为我做的工作已经失去市场价值。但是我还想说两句,毕竟在完成任务过程中,我得到了不少工作经验,想跟大家分享一下。可能有人疑问,老板为什么还要让你开发这样一个市场价值不高的东西呢,原因很简单,公司要降低产品开发成本,前面提到的已有开发技术会增加成本投入,毕竟价格定高了,卖不出去,这个我们不多谈吧,作为员工,踏踏实实完成任务就行。
现在进入正题,前面说,实现这个需求既不是靠第三方应用集成,也不是自己完整去开发,那还有第三条路吗?当然有,那就是从系统源码出发,作为一个已经很成熟的系统,只要不是很变态的功能,你都能通过定制修改编译源码来达到目的。我们知道Android源码中默认的有三种输入法:英文、中文、日文,对应的工程代码路径为:
<android_root>/packages/inputmethods/LatinIME/ <android_root>/packages/inputmethods/PinyinIME/ <android_root>/packages/inputmethods/OpenWnn/
其中Latin输入法支持的语种最多,可惜唯独不支持中文输入,没关系,我们可以曲线救国,不是还有一个拼音输入法吗!现在思路有了,实现要分两步:
第一步:解决输入法焦点问题
也就是修改原生输入法,使之支持TV操作,具体实现过程参见我上上篇博客:Android TV定制输入法
第二步:解决多输入法切换问题
前面很明了,我们要满足需求,系统得集成两种输入法:LatinIME和PinyinIME,那就牵涉到多输入法应用切换问题,这个问题我们不能交给用户去处理,Android默认输入法是LatinIME,当用户使用的系统语言环境是英语和泰语等语言时好说,使用中文时,就要让输入法切换成拼音输入法了。按照这样的思路,那只需要在切换系统语言的代码段里加入切换输入法函数就行了,我开始也是这么想的,但是没调试通。
参考了http://blog.csdn.net/ccwwff/article/details/6449761这篇博客,没解决问题,可能Android新版本API变了,后来看到了另一种方法:
Settings.Secure.putString( mContext.getContentResolver(),Settings.Secure.DEFAULT_INPUT_METHOD,myIME );倒是起作用,但是有个新问题,系统语言切换后,系统管理输入法的服务类InputMethodManagerService.java会强制切换默认输入法为LatinIME,如果这个类执行重置默认输入法方法在我切换输入法方法后面,那我的代码编写的切换输入法动作就会被覆盖。
问题的关键是如何控制我的设置默认输入法函数在系统那个方法后面执行,这个新问题出现后,首先想到的解决方法是写一个监听系统语言切换广播,通过广播来控制代码执行时机,不过可惜的是,未能如愿解决问题,系统语言切换后,重置默认输入法并不是立即执行,具体什么时候执行,还得深入研究。
那换一个思路吧,加个Handler消息延时发送,问题看起来好像解决了,因为调试后,切换输入法的确成功了,但是偶尔还会失败,通过Log打印发现,有时系统重置默认输入法方法还是会跑到我的切换函数后面执行。按照常规思路,把延时加长不就行了?但是增加延时值会导致系统响应操作变慢,况且这样做也不符合程序健壮性,只能另辟蹊径了!
还是从源码出发,细细研究InputMethodManagerService.java这个类,里面有个resetDefaultImeLocked方法,他是设置系统默认输入法的,我们就从这里入手,加一个限制条件:当系统语言为中文时,设置默认输入法为Pinyin输入法,到这里,问题貌似已经解决了。但是修改源码是有风险的,首先你无法保证你修改的东西会不会带来不可预知的问题,毕竟源码是经过时间考验的。其次,你修改的东西只是针对某个项目,可能其他方案就是要用原生的,所以这里还得加标志位,把修改带来的影响减小到最低,这里就要用到Android的属性系统(System Property)了。
这里简要介绍一下有关Android System Property:
顾名思义系统属性,肯定对整个系统全局共享。通常程序的执行以进程为单位各自相互独立,如何实现全局共享呢?System Properties是怎么一回事,又是如何实现的呢?
属性系统是android的一个重要特性。它作为一个服务运行,管理系统配置和状态。所有这些配置和状态都是属性。每个属性是一个键值对(key/value pair),其类型都是字符串。这些属性可能是有些资源的使用状态,进程的执行状态,系统的特有属性……
代码中大量存在SystemProperties.set()/SystemProperties.get();通过这两个接口可以对系统的属性进行读取/设置。
可以通过命令adb shell :getprop查看手机上所有属性状态值,或者getprop init.svc.bootanim制定查看某个属性状态,使用setprop init.svc.bootanim start设置某个属性的状态。
特别属性 :
如果属性名称以“ro.”开头,那么这个属性被视为只读属性。一旦设置,属性值不能改变。
如果属性名称以“persist.”开头,当设置这个属性时,其值也将写入/data/property。
如果属性名称以“net.”开头,当设置这个属性时,“net.change”属性将会自动设置,以加入到最后修改的属性名。(这是很巧妙的,netresolve模块的使用这个属性来追踪在net.*属性上的任何变化。)
属性“ ctrl.start ”和“ ctrl.stop ”是用来启动和停止服务。每一项服务必须在/init.rc中定义.系统启动时,与init守护进程将解析init.rc和启动属性服务。一旦收到设置“ ctrl.start ”属性的请求,属性服务将使用该属性值作为服务名找到该服务,启动该服务。这项服务的启动结果将会放入“ init.svc.<服务名>“属性中。客户端应用程序可以轮询那个属性值,以确定结果。
那在本问题中如何使用该属性系统呢?我们可以自定义以“persist.”开头的属性,如果你的项目引入了layoutlib.jar包,可以直接调用android.os.SystemProperties,如果是普通apk,则需要通过Java反射机制调用。我们先看看源码中SystemProperties这个类:
/* * Copyright (C) 2006 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.os; import java.util.ArrayList; import android.util.Log; /** * Gives access to the system properties store. The system properties * store contains a list of string key-value pairs. * * {@hide} */ public class SystemProperties { public static final int PROP_NAME_MAX = 31; public static final int PROP_VALUE_MAX = 91; private static final ArrayList<Runnable> sChangeCallbacks = new ArrayList<Runnable>(); private static native String native_get(String key); private static native String native_get(String key, String def); private static native int native_get_int(String key, int def); private static native long native_get_long(String key, long def); private static native boolean native_get_boolean(String key, boolean def); private static native void native_set(String key, String def); private static native void native_add_change_callback(); /** * Get the value for the given key. * @return an empty string if the key isn't found * @throws IllegalArgumentException if the key exceeds 32 characters */ public static String get(String key) { if (key.length() > PROP_NAME_MAX) { throw new IllegalArgumentException("key.length > " + PROP_NAME_MAX); } return native_get(key); } /** * Get the value for the given key. * @return if the key isn't found, return def if it isn't null, or an empty string otherwise * @throws IllegalArgumentException if the key exceeds 32 characters */ public static String get(String key, String def) { if (key.length() > PROP_NAME_MAX) { throw new IllegalArgumentException("key.length > " + PROP_NAME_MAX); } return native_get(key, def); } /** * Get the value for the given key, and return as an integer. * @param key the key to lookup * @param def a default value to return * @return the key parsed as an integer, or def if the key isn't found or * cannot be parsed * @throws IllegalArgumentException if the key exceeds 32 characters */ public static int getInt(String key, int def) { if (key.length() > PROP_NAME_MAX) { throw new IllegalArgumentException("key.length > " + PROP_NAME_MAX); } return native_get_int(key, def); } /** * Get the value for the given key, and return as a long. * @param key the key to lookup * @param def a default value to return * @return the key parsed as a long, or def if the key isn't found or * cannot be parsed * @throws IllegalArgumentException if the key exceeds 32 characters */ public static long getLong(String key, long def) { if (key.length() > PROP_NAME_MAX) { throw new IllegalArgumentException("key.length > " + PROP_NAME_MAX); } return native_get_long(key, def); } /** * Get the value for the given key, returned as a boolean. * Values 'n', 'no', '0', 'false' or 'off' are considered false. * Values 'y', 'yes', '1', 'true' or 'on' are considered true. * (case sensitive). * If the key does not exist, or has any other value, then the default * result is returned. * @param key the key to lookup * @param def a default value to return * @return the key parsed as a boolean, or def if the key isn't found or is * not able to be parsed as a boolean. * @throws IllegalArgumentException if the key exceeds 32 characters */ public static boolean getBoolean(String key, boolean def) { if (key.length() > PROP_NAME_MAX) { throw new IllegalArgumentException("key.length > " + PROP_NAME_MAX); } return native_get_boolean(key, def); } /** * Set the value for the given key. * @throws IllegalArgumentException if the key exceeds 32 characters * @throws IllegalArgumentException if the value exceeds 92 characters */ public static void set(String key, String val) { if (key.length() > PROP_NAME_MAX) { throw new IllegalArgumentException("key.length > " + PROP_NAME_MAX); } if (val != null && val.length() > PROP_VALUE_MAX) { throw new IllegalArgumentException("val.length > " + PROP_VALUE_MAX); } native_set(key, val); } public static void addChangeCallback(Runnable callback) { synchronized (sChangeCallbacks) { if (sChangeCallbacks.size() == 0) { native_add_change_callback(); } sChangeCallbacks.add(callback); } } static void callChangeCallbacks() { synchronized (sChangeCallbacks) { //Log.i("foo", "Calling " + sChangeCallbacks.size() + " change callbacks!"); if (sChangeCallbacks.size() == 0) { return; } ArrayList<Runnable> callbacks = new ArrayList<Runnable>(sChangeCallbacks); for (int i=0; i<callbacks.size(); i++) { callbacks.get(i).run(); } } } }注意上面,这个类是加了{@hide}标签的,一般不允许直接调用,需要通过反射机制调用,反射怎么写呢,我给个范例吧:
package com.gotech.tv.launcher.util; /** * @author john * @created 2016-2-19 */ import java.lang.reflect.Method; public class SystemPropertiesUtil { public static String get(String key) { String ret = null; try { Class<?> clazz = Class.forName("android.os.SystemProperties"); Method mthd = clazz.getMethod("get", new Class[] { String.class }); mthd.setAccessible(true); Object obj = mthd.invoke(clazz, new Object[] { key }); if (obj != null && obj instanceof String) { ret = (String) obj; } } catch (Exception e) { e.printStackTrace(); } return ret; } public static String get(String key, String def) { String ret = def; try { Class<?> clazz = Class.forName("android.os.SystemProperties"); Method mthd = clazz.getMethod("get", new Class[] { String.class, String.class }); mthd.setAccessible(true); Object obj = mthd.invoke(clazz, new Object[] { key, def }); if (obj != null && obj instanceof String) { ret = (String) obj; } } catch (Exception e) { e.printStackTrace(); } return ret; } public static boolean getBoolean(String key, boolean def) { boolean ret = def; try { Class<?> clazz = Class.forName("android.os.SystemProperties"); Method mthd = clazz.getMethod("getBoolean", new Class[] { String.class, boolean.class }); mthd.setAccessible(true); Object obj = mthd.invoke(clazz, new Object[] { key, def }); if (obj != null && obj instanceof Boolean) { ret = (Boolean) obj; } } catch (Exception e) { e.printStackTrace(); } return ret; } public static int getInt(String key, int def) { int ret = def; try { Class<?> clazz = Class.forName("android.os.SystemProperties"); Method mthd = clazz.getMethod("getInt", new Class[] { String.class, int.class }); mthd.setAccessible(true); Object obj = mthd.invoke(clazz, new Object[] { key, def }); if (obj != null && obj instanceof Integer) { ret = (Integer) obj; } } catch (Exception e) { e.printStackTrace(); } return ret; } public static long getLong(String key, long def) { long ret = def; try { Class<?> clazz = Class.forName("android.os.SystemProperties"); Method mthd = clazz.getMethod("getLong", new Class[] { String.class, long.class }); mthd.setAccessible(true); Object obj = mthd.invoke(clazz, new Object[] { key, def }); if (obj != null && obj instanceof Long) { ret = (Long) obj; } } catch (Exception e) { e.printStackTrace(); } return ret; } public static void set(String key, String value) { try { Class<?> clazz = Class.forName("android.os.SystemProperties"); Method mthd = clazz.getMethod("set", new Class[] { String.class, String.class }); mthd.setAccessible(true); mthd.invoke(clazz, new Object[] { key, value }); } catch (Exception e) { e.printStackTrace(); } } }下面就是在项目中自定义一个系统属性,我们在启动Activity的onCreate()里添加:
// john add for default IME setting SystemProperties.set("persist.sys.sync.ime" , "true");最后贴上源码修改部分,在InputMethodManagerService.java的resetDefaultImeLocked方法添加:
private void resetDefaultImeLocked(Context context) { // Do not reset the default (current) IME when it is a 3rd-party IME if (mCurMethodId != null && !InputMethodUtils.isSystemIme(mMethodMap.get(mCurMethodId))) { return; } InputMethodInfo defIm = null; for (InputMethodInfo imi : mMethodList) { if (defIm == null) { if (InputMethodUtils.isValidSystemDefaultIme( mSystemReady, imi, context)) { defIm = imi; Slog.i(TAG, "Selected default: " + imi.getId()); } } } if (defIm == null && mMethodList.size() > 0) { //john add for sync system language and input method --> Slog.i(TAG, "persist.sys.sync.ime : " +SystemProperties.get("persist.sys.sync.ime", "false")+"************Language : "+mRes.getConfiguration().locale.getLanguage()); if (SystemProperties.get("persist.sys.sync.ime", "false").equals("true") && mRes.getConfiguration().locale.getLanguage().equals("zh")) { for (InputMethodInfo imi : mMethodList) { if (imi.getId().equals(REMOTE_IME)) { defIm = imi; Slog.i(TAG, "Custom default : " + defIm.getId()); } } }// <--end else { defIm = InputMethodUtils.getMostApplicableDefaultIME(mSettings.getEnabledInputMethodListLocked()); Slog.i(TAG, "No default found, using " + defIm.getId()); } } if (defIm != null) { setSelectedInputMethodAndSubtypeLocked(defIm, NOT_A_SUBTYPE_ID, false); } }附上我们自定义默认输入法的ID:
/** * john add for Chinese Input */ private static final String REMOTE_IME="com.hisilicon.android.inputmethod.remote/.RemoteIME";