Bootstrap

Android插件换肤功能实战

       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>

 

点击下载完整代码

效果:

 

       欢迎关注我的公众号一起交流学习

     

 

;