Android 使用 hook技术实现一键换肤

简言:

工作中我们经常会收到产品经理提出手机需要有更换皮肤的功能,现在市面上大多数都是以主题换肤为主的,今天我这里介绍另一种,利用hook技术来实现一键换肤功能。

hook简介:
Hook翻译过来是钩子的意思,我们都知道无论是手机还是电脑运行的时候都依赖系统各种各样的API,当某些API不能满足我们的要求时,我们就得去修改某些api,使之能满足我们的要求。这样api hook就自然而然的出现了。我们可以通过api hook,改变一个系统api的原有功能。基本的方法就是通过hook“接触”到需要修改的api函数入口点,改变它的地址指向新的自定义的函数。当然这种技术同样适用于Android系统,在Android开发中,我们同样能利用Hook的原理让系统某些方法运行时调用的是我们定义的方法,从而满足我们的要求。

哪些资源可以替换呢?
我们可以替换的资源有动画、背景图片、字体、字体颜色、字体大小、音频、视频等,总的来说,res目录下的所有资源都可以被替换掉。

换肤的步骤
在这里插入图片描述
Android是如何实例化布局的呢?
详细的流程这篇文章已经写的很详细了Android布局加载流程 这里就不做多的探讨了。下面直接到我们的主题。

实现一键换肤
第一步:创建一个SkinFactory类让它继承LayoutInflater.Factory2,该类的功能是:1、拦截系统创建view的过程,由我们自己创建view;2、收集需要换肤的view;3、换肤操作。下面贴出源码:

public class SkinFactory implements LayoutInflater.Factory2 {

    private AppCompatDelegate mDelegate;//预定义一个委托类,它负责按照系统的原有逻辑来创建view

    private List<SkinView> listCacheSkinView = new ArrayList<>();//我自定义的list,缓存所有可以换肤的View对象

    /**
     * 给外部提供一个set方法
     *
     * @param mDelegate
     */
    public void setDelegate(AppCompatDelegate mDelegate) {
        this.mDelegate = mDelegate;
    }


    /**
     * Factory2 是继承Factory的,所以,我们这次是主要重写Factory的onCreateView逻辑,就不必理会Factory的重写方法了
     *
     * @param name
     * @param context
     * @param attrs
     * @return
     */
    @Override
    public View onCreateView(String name, Context context, AttributeSet attrs) {
        return null;
    }

    /**
     * @param parent
     * @param name
     * @param context
     * @param attrs
     * @return
     */
    @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {

        // TODO: 关键点1:执行系统代码里的创建View的过程,我们只是想加入自己的思想,并不是要全盘接管
        View view = mDelegate.createView(parent, name, context, attrs);//系统创建出来的时候有可能为空,你问为啥?请全文搜索 “标记标记,因为” 你会找到你要的答案
        if (view == null) {//万一系统创建出来是空,那么我们来补救
            mConstructorArgs[0] = context;
            try {
                if (-1 == name.indexOf('.')) {//不包含. 说明不带包名,那么我们帮他加上包名
                    view = createViewByPrefix(context, name, prefixs, attrs);
                } else {//包含. 说明 是权限定名的view name,
                    view = createViewByPrefix(context, name, null, attrs);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        //TODO: 关键点2 收集需要换肤的View
        collectSkinView(context, attrs, view);

        return view;
    }

    /**
     * TODO: 收集需要换肤的控件
     * 收集的方式是:通过自定义属性isSupport,从创建出来的很多View中,找到支持换肤的那些,保存到map中
     */
    private void collectSkinView(Context context, AttributeSet attrs, View view) {
        // 获取我们自己定义的属性
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.Skinable);
        boolean isSupport = a.getBoolean(R.styleable.Skinable_isSupport, false);
        if (isSupport) {//找到支持换肤的view
            final int Len = attrs.getAttributeCount();
            HashMap<String, String> attrMap = new HashMap<>();
            for (int i = 0; i < Len; i++) {//遍历所有属性
                String attrName = attrs.getAttributeName(i);
                String attrValue = attrs.getAttributeValue(i);
                attrMap.put(attrName, attrValue);//全部存起来
            }

            SkinView skinView = new SkinView();
            Log.e("changeSkin", "skinView = " + view.getId());
            skinView.view = view;
            skinView.attrsMap = attrMap;
            listCacheSkinView.add(skinView);//将可换肤的view,放到listCacheSkinView中
        }

    }

    /**
     * 公开给外界的换肤入口
     */
    public void changeSkin() {
        for (SkinView skinView : listCacheSkinView) {
            skinView.changeSkin();
        }
    }

    static class SkinView {
        View view;
        HashMap<String, String> attrsMap;

        /**
         * 真正的换肤操作
         */
        public void changeSkin() {
            if (!TextUtils.isEmpty(attrsMap.get("background"))) {//属性名,例如,这个background,text,textColor....
                int bgId = Integer.parseInt(attrsMap.get("background").substring(1));//属性值,R.id.XXX ,int类型,
                // 这个值,在app的一次运行中,不会发生变化
                String attrType = view.getResources().getResourceTypeName(bgId); // 属性类别:比如 drawable ,color
                if (TextUtils.equals(attrType, "drawable")) {//区分drawable和color
                    view.setBackgroundDrawable(SkinEngine.getInstance().getDrawable(bgId));//加载外部资源管理器,拿到外部资源的drawable
                } else if (TextUtils.equals(attrType, "color")) {
                    Log.e("changeSkin", view.getId() + "");
                    view.setBackgroundColor(SkinEngine.getInstance().getColor(bgId));
                }
            }


            if (view instanceof TextView) {
                if (!TextUtils.isEmpty(attrsMap.get("textColor"))) {
                    int textColorId = Integer.parseInt(attrsMap.get("textColor").substring(1));
                    ((TextView) view).setTextColor(SkinEngine.getInstance().getColor(textColorId));
                }
            }

            //那么如果是自定义组件呢
//            if (view instanceof EmptyLayout) {
//                //那么这样一个对象,要换肤,就要写针对性的方法了,每一个控件需要用什么样的方式去换,尤其是那种,自定义的属性,怎么去set,
//                // 这就对开发人员要求比较高了,而且这个换肤接口还要暴露给 自定义View的开发人员,他们去定义
//                // ....
//            }
        }

    }

    /**
     * 所谓hook,要懂源码,懂了之后再劫持系统逻辑,加入自己的逻辑。
     * 那么,既然懂了,系统的有些代码,直接拿过来用,也无可厚非。
     */
    //*******************************下面一大片,都是从源码里面抄过来的,并不是我自主设计******************************
    // 你问我抄的哪里的?到 AppCompatViewInflater类源码里面去搜索:view = createViewFromTag(context, name, attrs);
    static final Class<?>[] mConstructorSignature = new Class[]{Context.class, AttributeSet.class};//
    final Object[] mConstructorArgs = new Object[2];//View的构造函数的2个"实"参对象
    private static final HashMap<String, Constructor<? extends View>> sConstructorMap = new HashMap<String, Constructor<? extends View>>();//用映射,将View的反射构造函数都存起来
    static final String[] prefixs = new String[]{//安卓里面控件的包名,就这么3种,这个变量是为了下面代码里,反射创建类的class而预备的
            "android.widget.",
            "android.view.",
            "android.webkit."
    };

    /**
     * 反射创建View
     *
     * @param context
     * @param name
     * @param prefixs
     * @param attrs
     * @return
     */
    private final View createViewByPrefix(Context context, String name, String[] prefixs, AttributeSet attrs) {

        Constructor<? extends View> constructor = sConstructorMap.get(name);
        Class<? extends View> clazz = null;

        if (constructor == null) {
            try {
                if (prefixs != null && prefixs.length > 0) {
                    for (String prefix : prefixs) {
                        clazz = context.getClassLoader().loadClass(
                                prefix != null ? (prefix + name) : name).asSubclass(View.class);//控件
                        if (clazz != null) {
                            break;
                        }
                    }
                } else {
                    if (clazz == null) {
                        clazz = context.getClassLoader().loadClass(name).asSubclass(View.class);
                    }
                }
                if (clazz == null) {
                    return null;
                }
                constructor = clazz.getConstructor(mConstructorSignature);//拿到 构造方法,
            } catch (Exception e) {
                e.printStackTrace();
                return null;
            }
            constructor.setAccessible(true);//
            sConstructorMap.put(name, constructor);//然后缓存起来,下次再用,就直接从内存中去取
        }
        Object[] args = mConstructorArgs;
        args[1] = attrs;
        try {
            //通过反射创建View对象
            final View view = constructor.newInstance(args);//执行构造函数,拿到View对象
            return view;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
    //***********************************************************************************************************************************

}

第二步:创建SkinEngine类,用来做初始化外部资源管理器和内部资源管理器,换肤时,通过两个资源管理器配合获取color值或者drawable下的值。源码如下:

public class SkinEngine {

    //单例
    @SuppressLint("StaticFieldLeak")
    private final static SkinEngine INSTANCE = new SkinEngine();

    public static SkinEngine getInstance() {
        return INSTANCE;
    }

    private SkinEngine() {
    }

    public void init(Context context) {
        mContext = context.getApplicationContext();
        mInnerResource = mContext.getResources();
        //使用application的目的是,如果万一传进来的是Activity对象
        //那么它被静态对象instance所持有,这个Activity就无法释放了
    }

    private Resources mOutResource;// TODO: 外部资源管理器
    private Resources mInnerResource;// TODO: 内部资源管理器

    private Context mContext;//上下文
    private String mOutPkgName;// TODO: 外部资源包的packageName

    /**
     * TODO: 加载外部资源包
     */
    public void load(final String path) {//path 是外部传入的apk文件名
        File file = new File(path);
        if (!file.exists()) {
            return;
        }
        //取得PackageManager引用
        PackageManager mPm = mContext.getPackageManager();
        //“检索在包归档文件中定义的应用程序包的总体信息”,说人话,外界传入了一个apk的文件路径,这个方法,拿到这个apk的包信息,这个包信息包含什么?
        PackageInfo mInfo = mPm.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES);
        mOutPkgName = mInfo.packageName;//先把包名存起来
        AssetManager assetManager;//资源管理器
        try {
            //TODO: 关键技术点3 通过反射获取AssetManager 用来加载外面的资源包
            assetManager = AssetManager.class.newInstance();//反射创建AssetManager对象,为何要反射?使用反射,是因为他这个类内部的addAssetPath方法是hide状态
            //addAssetPath方法可以加载外部的资源包
            Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);//为什么要反射执行这个方法?因为它是hide的,不直接对外开放,只能反射调用
            addAssetPath.invoke(assetManager, file.getAbsolutePath());//反射执行方法
            mOutResource = new Resources(assetManager,//参数1,资源管理器
                    mContext.getResources().getDisplayMetrics(),//这个好像是屏幕参数
                    mContext.getResources().getConfiguration());//资源配置
            //最终创建出一个 "外部资源包"mOutResource ,它的存在,就是要让我们的app有能力加载外部的资源文件
        } catch (Exception e) {
            e.printStackTrace();
        }

    }

    /**
     * 提供外部资源包里面的颜色
     * @param resId
     * @return
     */
    public int getColor(int resId) {
        if (mOutResource == null) {
            return resId;
        }
        Log.e("changeSkin", "resId=" + resId + "mOutResource = " + mOutResource.toString() + "mOutPkgName=" + mOutPkgName);
        String resName = mInnerResource.getResourceEntryName(resId);
        int outResId = mOutResource.getIdentifier(resName, "color", mOutPkgName);
        if (outResId == 0) {
            return resId;
        }
        return mOutResource.getColor(outResId);
    }

    /**
     * 提供外部资源包里的图片资源
     * @param resId
     * @return
     */
    public Drawable getDrawable(int resId) {//获取图片
        if (mOutResource == null) {
            return ContextCompat.getDrawable(mContext, resId);
        }
        String resName = mInnerResource.getResourceEntryName(resId);
        int outResId = mOutResource.getIdentifier(resName, "drawable", mOutPkgName);
        if (outResId == 0) {
            return ContextCompat.getDrawable(mContext, resId);
        }
        return mOutResource.getDrawable(outResId);
    }

    //..... 这里还可以提供外部资源包里的String,font等等等,只不过要手动写代码来实现getXX方法
}

第三步:在style文件床架自定义属性:

  <declare-styleable name="Skinable">
        <!--TODO: isSupport=true标识当前控件支持换肤-->
        <attr name="isSupport" format="boolean" />
    </declare-styleable>

第四步:在Application的oncreate方法下初始化我们的框架:

public class MyApplication extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        SkinEngine.getInstance().init(this);//初始化换肤
    }
}

第五步:创建我们的换肤插件
在这里插入图片描述
下面是我工程项目的目录
在这里插入图片描述
第六步:把我们skin下的res里面的内容全部替换为app目录下的res下的文件全部内容

第七步:编译出apk文件,并且把它命名为:skin2.apk(这个是跟我们app资源是一样的)
在这里插入图片描述
然后我们改一下里面的颜色,让其达到换肤的目的,再重新编译一次,生成的apk文件命名为:skin.apk(这是更换了颜色的),最后我们复制这两个apk文件到我们的手机中去,我是复制到我手机的根目录下。

第八步:配置我们的MainActivity,注意:这里因为需要从手机内存读取apk文件,所以需要读取的权限。

public class MainActivity extends AppCompatActivity {
    protected static String[] skins = new String[]{"skin.apk", "skin2.apk"};

    protected static String mCurrentSkin = null;

    private SkinFactory mSkinFactory;
    private Button btn_change;

    // 要申请的权限
    private String[] permissions = {Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE};

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        mSkinFactory = new SkinFactory();
        mSkinFactory.setDelegate(getDelegate());
        LayoutInflater layoutInflater = LayoutInflater.from(this);
        Log.d("layoutInflaterTag", layoutInflater.toString());
        layoutInflater.setFactory2(mSkinFactory);
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        // 版本判断。当手机系统大于 23 时,才有必要去判断权限是否获取
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {

            // 检查该权限是否已经获取
            int i = ContextCompat.checkSelfPermission(this, permissions[0]);
            // 权限是否已经 授权 GRANTED---授权  DINIED---拒绝
            if (i != PackageManager.PERMISSION_GRANTED) {
                // 如果没有授予该权限,就去提示用户请求
                startRequestPermission();
            }
        }
        btn_change = findViewById(R.id.btn_change);
        btn_change.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                changeSkin(getPath());
            }
        });
    }

    // 开始提交请求权限
    private void startRequestPermission() {
        ActivityCompat.requestPermissions(this, permissions, 321);
    }


    // 用户权限 申请 的回调方法
    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);

        if (requestCode == 321) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                if (grantResults[0] != PackageManager.PERMISSION_GRANTED) {
                    // 判断用户是否 点击了不再提醒。(检测该权限是否还可以申请)
                    boolean b = shouldShowRequestPermissionRationale(permissions[0]);
                    if (!b) {
                        // 用户还是想用我的 APP 的
                        // 提示用户去应用设置界面手动开启权限
                        startRequestPermission();
                    } else {
                        finish();
                    }
                } else {
                    Toast.makeText(this, "权限获取成功", Toast.LENGTH_SHORT).show();
                }

            }
        }
    }


    /**
     * 等控件创建完成并且可交互之后,再换肤
     */
    @Override
    protected void onResume() {
        super.onResume();
        Log.d("changeTag", null == mCurrentSkin ? "currentSkin是空" : mCurrentSkin);
        if (null != mCurrentSkin) {
            changeSkin(mCurrentSkin); // 换肤操作必须在setContentView之后
        }
    }


    /**
     * 做一个切换方法
     *
     * @return
     */
    protected String getPath() {
        String path;
        if (null == mCurrentSkin) {
            path = skins[0];
        } else if (skins[0].equals(mCurrentSkin)) {
            path = skins[1];
        } else if (skins[1].equals(mCurrentSkin)) {
            path = skins[0];
        } else {
            return "unknown skin";
        }
        return path;
    }

    public void changeSkin(String path) {
        File skinFile = new File(Environment.getExternalStorageDirectory(), path);
        Log.e("changeSkin", skinFile.getAbsolutePath());
        SkinEngine.getInstance().load(skinFile.getAbsolutePath());
        mSkinFactory.changeSkin();
        mCurrentSkin = path;
    }
}

好了到这步就算完成了,下面看一下我项目运行的效果:
在这里插入图片描述
最后在这里贴上项目地址:hook一键换肤


版权声明:本文为weixin_38203696原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。