Android App实现换肤有多种思路,有的是通过内置资源的方式,有的是通过设置相同签名并且AndroidManifest.xml中配置相同android:sharedUserId使得两个apk运行在同一个进程中来互相访问数据。但是这些方式都有其局限性,实现不够灵活。今天来聊一下通过插件的方式换肤的原理及实现。
这种实现方式的大概思路是这样的:
1)创建宿主工程,完成对应的UI页面,确定哪些资源需要动态替换。
2)创建单独的Android子工程,将需要替换的资源(color、string、image等)打包进apk,但是要保证资源的名字和主工程中的完全一致。
3)在宿主工程生成对应于插件资源的Resources对象,通过Resouces对象加载插件apk的资源,从而实现换肤的目的。
上面提到了Resources类,首先来认识一下这个类,它的职责是负责App中资源的管理。一个App中的某一个固定配置(可以理解为某一个固定的手机)只会对应于一个Resources对象。程序运行时可以通过Context的getResources()方法得到当前应用的Resouces对象,通过Resouces对象就可以访问res目录下的资源。如果需要访问App之外的资源(如插件apk),则需要创建对应于当前资源的Resources对象。
那么,这里的关键就是怎么得到Resouces对象。
实现插件换肤的关键就是下面一段代码,它最终创建了一个与我们的插件资源相关的Resources对象,可以通过这个对象访问皮肤插件的资源。
try {
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, mDexPath);
mAssetManager = assetManager;
} catch (Exception e) {
e.printStackTrace();
}
Resources currentRes = this.getResources();
mResources = new Resources(mAssetManager, currentRes.getDisplayMetrics(),
currentRes.getConfiguration());
Resources类的构造函数需要三个参数,其中最重要的就是第一个参数AssetManager,其他两个都是和设备相关的参数,直接用当前应用的配置就好了。AssetManager需要调用addAssetPath(resDir)方法是把资源目录中的资源加载到AssetManager对象中,由于这个方法在sdk中是hide的,只能通过反射来调用。有了Resources对象,我们就可以通过它来访问插件apk里面的各种资源了。
原理讲完了,来看下具体的实现。正常情况插件皮肤包应该是从服务器下载,这里为了演示方便直接将包置于assets目录下。
定义宿主工程,首先定义了宿主工程用到的一些常量,插件包个数、插件apk的名字以及需要替换的资源的名字,我们会替换drawable、text、size和color四种资源,其他资源的替换当然也是类似的。
public class SkinConstants {
public static final int SKIN_PLUGIN_NUMBER = 2;
public static final String DRAWABLE_ID = "change_skin_drawable";
public static final String TEXT_ID = "change_skin_text";
public static final String TEXT_SIZE_ID = "change_skin_text_size";
public static final String TEXT_COLOR_ID = "change_skin_text_color";
//插件文件名
public static String PLUGIN_FILE_NAME1 = "skin_plugin1.apk";
public static String PLUGIN_FILE_NAME2 = "skin_plugin2.apk";
}
首先在Activity中定义了皮肤插件资源和皮肤包的包名,创建了插件apk的缓存目录,这里使用了依赖注入来定义View对象。
public class ChangeSkinActivity extends Activity implements View.OnClickListener {
//皮肤包资源对象
private Resources mSkinResouces[] = new Resources[SkinConstants.SKIN_PLUGIN_NUMBER];
//皮肤包的包名
private String mPackageName[] = new String[SkinConstants.SKIN_PLUGIN_NUMBER];
@ViewInjection(R.id.change_skin_image)
private ImageView imageView;
@ViewInjection(R.id.change_skin_text)
private TextView textView;
@ViewInjection(R.id.change_skin_btn1)
private Button btn1;
@ViewInjection(R.id.change_skin_btn2)
private Button btn2;
private boolean isInited = false;
//缓存的皮肤包目录
private String mCachedFileName1;
private String mCachedFileName2;
private CustomLoadingDialog mLoadingDialog;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_change_skin);
ViewInjectionTools.register(this);
mCachedFileName1 = ChangeSkinActivity.this.getCacheDir() + File.separator + SkinConstants.PLUGIN_FILE_NAME1;
mCachedFileName2 = ChangeSkinActivity.this.getCacheDir() + File.separator + SkinConstants.PLUGIN_FILE_NAME2;
mLoadingDialog = new CustomLoadingDialog(this, "正在加载插件");
btn1.setOnClickListener(this);
btn2.setOnClickListener(this);
}
然后在onClick方法中会去检查是否已经缓存了皮肤插件apk,如果没有,则先进行缓存,否则直接换肤。
@Override
public void onClick(View v) {
if (v.getId() == R.id.change_skin_btn1) {
//apk还没有拷贝到缓存下,则先异步拷贝plugin apk
if (!isInited) {
mLoadingDialog.show();
asycLoadPlugin(0);
return;
}
changeSkin(0);
} else if (v.getId() == R.id.change_skin_btn2) {
//apk还没有拷贝到缓存下,则先异步拷贝plugin apk
if (!isInited) {
mLoadingDialog.show();
asycLoadPlugin(1);
return;
}
changeSkin(1);
}
}
缓存皮肤插件apk的操作是在子线程中异步去做,将插件文件从assets目录中拷贝到缓存目录下。缓存完成后,发送Message去通知Handler进行换肤的操作。
private void asycLoadPlugin(final int index) {
new Thread(new Runnable() {
@Override
public void run() {
copyPlugin2Cache(SkinConstants.PLUGIN_FILE_NAME1);
copyPlugin2Cache(SkinConstants.PLUGIN_FILE_NAME2);
mHandler.obtainMessage(index).sendToTarget();
}
private void copyPlugin2Cache(String fileName) {
InputStream is = null;
FileOutputStream fo = null;
try {
is = getResources().getAssets().open(fileName);
if (is == null) {
return;
}
if (SkinConstants.PLUGIN_FILE_NAME1.equals(fileName)) {
fo = new FileOutputStream(mCachedFileName1);
} else {
fo = new FileOutputStream(mCachedFileName2);
}
byte[] buffer = new byte[4 * 1024];
int len = -1;
while ((len = is.read(buffer)) != -1) {
fo.write(buffer, 0, len);
fo.flush();
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (is != null) {
try {
is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (fo != null) {
try {
fo.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}).start();
}
在Handler中得到皮肤插件的包名和对应于每个包的Resources对象。包名通过PackageManager获得,Resources对象的创建正如我们在最开始描述的那样。
private Handler mHandler = new Handler(){
@Override
public void handleMessage(Message msg) {
if (mLoadingDialog.isShowing()) {
mLoadingDialog.dismiss();
}
getPackageNames();
mSkinResouces[0] = loadResources(mCachedFileName1);
mSkinResouces[1] = loadResources(mCachedFileName2);
if (mSkinResouces[0] != null && mSkinResouces[1] != null) {
isInited = true;
changeSkin(msg.what);
}
}
};
private void getPackageNames() {
PackageInfo mInfo1 = getPackageManager().getPackageArchiveInfo(mCachedFileName1, PackageManager.GET_ACTIVITIES);
mPackageName[0] = mInfo1.packageName;
L.d("package name 1 is " + mPackageName[0]);
PackageInfo mInfo2 = getPackageManager().getPackageArchiveInfo(mCachedFileName2, PackageManager.GET_ACTIVITIES);
mPackageName[1] = mInfo2.packageName;
L.d("package name 2 is " + mPackageName[1]);
}
/**
* 生成对应apk的Resources对象
*
* @param dexFile
* @return
*/
private Resources loadResources(String dexFile) {
AssetManager assetManager = null;
try {
assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, dexFile);
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
Resources superRes = getResources();
Resources skinRes = new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
return skinRes;
}
最终来到了真正实现换肤的地方,通过Resources的getIdentifier方法拿到了皮肤插件包中的资源的id,继而拿到对应的资源对象,设置到TextView的属性。比如,先通过给getIdentifier()方法传递"drawable"参数拿到图片的drawableId,然后再通过Resources的getDrawable(drawableId)得到drawable对象。需要注意的是,我们是不能直接将drawableId设置给TextView的,这是因为drawableId在Android系统中只是对应于一个0x7f开头的值,如果直接设置,系统会自动搜索宿主工程中对应于这个值得资源,自然就会拿到错误的资源。
private void changeSkin(int i) {
int drawableId = mSkinResouces[i].getIdentifier(SkinConstants.DRAWABLE_ID, "drawable", mPackageName[i]);
int textId = mSkinResouces[i].getIdentifier(SkinConstants.TEXT_ID, "string", mPackageName[i]);
int textSizeId = mSkinResouces[i].getIdentifier(SkinConstants.TEXT_SIZE_ID, "dimen", mPackageName[i]);
int textColorId = mSkinResouces[i].getIdentifier(SkinConstants.TEXT_COLOR_ID, "color", mPackageName[i]);
imageView.setBackgroundDrawable(mSkinResouces[i].getDrawable(drawableId));
textView.setText(mSkinResouces[i].getString(textId));
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mSkinResouces[i].getDimension(textSizeId));
textView.setTextColor(mSkinResouces[i].getColor(textColorId));
}
最后定义插件apk,里面主要定义了一些资源,这些资源的名字和宿主工程中的资源名必须保持一致。然后将插件包打成apk放在宿主工程的assets目录下。
<resources>
<string name="app_name">SkinPlugin</string>
<string name="change_skin_text">我来自插件2</string>
</resources>
<resources>
<color name="change_skin_text_color">#00b2ee</color>
</resources>
<resources>
<dimen name="change_skin_text_size">30dp</dimen>
</resources>
效果:
欢迎关注我的公众号一起交流学习