Android 動態換膚技術原理 | 實踐 及總結

實現的效果圖

動態換膚一般實現的原理

  1. 對頁面需要換膚的View進行標記
  2. Activity#setContentView()加載view時獲取到標記的view(後面會說是要怎麼獲取到)
  3. 創建一個Library項目製作我們的皮膚包(res下的資源名稱需要與app使用的一致,換膚就是通過使用的資源名稱去皮膚包里加載相同名字的資源)
  4. 創建皮膚包對應的Resources對象(用於加載皮膚包內的資源)
  5. 點擊換膚將我們標記的View的一些屬性上設置的值修改爲皮膚包裏的值,這樣就達到換膚的效果

一、對頁面需要換膚的View進行標記

這一步是相對簡單的,只要自定義一個屬性即可;在獲取View的時候判斷有無這個屬性 有就將這個view存起來
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:skin="http://schemas.android.com/apk/azhon-skin"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp"
    tools:context=".MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@color/bg_1"
        android:text="我是一個TextView"
        android:textColor="@color/title_1"
        android:textSize="16sp"
        skin:enable="true" />

    <Button
        android:id="@+id/btn_dark"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:background="@color/bg_2"
        android:text="@string/btn_text"
        android:textColor="@color/title_2"
        skin:enable="true" />

    <Button
        android:id="@+id/btn_default"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:background="@color/bg_3"
        android:text="@string/btn_reset_text"
        android:textColor="@color/title_3" />
</LinearLayout>

skin:enable="true"這個就是自定的一個屬性取值爲boolean,如果爲true就表示在換膚的時候需要去皮膚包加載對應的資源

二、獲取在佈局標記好的View

這裏使用的是自定義佈局加載器LayoutInflaterLayoutInflater.Factory2來監聽View的創建;下面我們來通過閱讀源碼來具體說一下爲什麼使用的這個:

  • 查看AppCompatActivity的setContentView()方法
public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
}
  • 接着繼續調用了getDelegate()的setContentView()方法
// AppCompatActivity.java
@Override
public void setContentView(@LayoutRes int layoutResID) {
    getDelegate().setContentView(layoutResID);
}

getDelegate()獲取到的是AppCompatDelegate這個抽象類的實現類,而他的實現類就只有一個AppCompatDelegateImpl

  • 接着調用了AppCompatDelegateImpl的setContentView()
// AppCompatDelegateImpl.java
@Override
public void setContentView(int resId) {
    ensureSubDecor();
    ViewGroup contentParent = mSubDecor.findViewById(android.R.id.content);
    contentParent.removeAllViews();
    //重點就是這樣代碼,通過佈局加載器加載xml文件
    LayoutInflater.from(mContext).inflate(resId, contentParent);
    mAppCompatWindowCallback.getWrapped().onContentChanged();
}

閱讀到這裏就可以看到有用的代碼了LayoutInflater.from(mContext).inflate(resId, contentParent)加載我們的xml佈局文件,他傳入了我們的佈局資源idandroid.R.id.content這個ViewGroup;有了解過Activity的佈局層次結構的同學肯定就知道是什麼了。

  • 接着往下看LayoutInflater的inflate()方法
// LayoutInflater.java
//No.1
public View inflate(@LayoutRes int resource, @Nullable ViewGroup root) {
    return inflate(resource, root, root != null);
}

//No.2 接着調用了
public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
    final Resources res = getContext().getResources();
    if (DEBUG) {
        Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
                + Integer.toHexString(resource) + ")");
    }
    final XmlResourceParser parser = res.getLayout(resource);
    try {
        return inflate(parser, root, attachToRoot);
    } finally {
        parser.close();
    }
}

//No.3 接着調用了
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
    synchronized (mConstructorArgs) {
        Trace.traceBegin(Trace.TRACE_TAG_VIEW, "inflate");

			// 省略若干源代碼.... 

            if (TAG_MERGE.equals(name)) {
                if (root == null || !attachToRoot) {
                    throw new InflateException("<merge /> can be used only with a valid "
                            + "ViewGroup root and attachToRoot=true");
                }
                rInflate(parser, root, inflaterContext, attrs, false);
            } else {
                // Temp is the root view that was found in the xml
                final View temp = createViewFromTag(root, name, inflaterContext, attrs);
                ViewGroup.LayoutParams params = null;
                
 			// 省略若干源代碼.... 
            
            }
      }
}

調用inflate()最終調用了createViewFromTag()這個方法根據佈局寫的代碼開始創建對應的View實體,繼續向下查看createViewFromTag()的代碼

// LayoutInflater.java
// No.1
private View createViewFromTag(View parent, String name, Context context, AttributeSet attrs) {
    return createViewFromTag(parent, name, context, attrs, false);
}

// No.2
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
        boolean ignoreThemeAttr) {
        
   // 省略若干源代碼.... 
   
    try {
        View view;
        if (mFactory2 != null) {
            view = mFactory2.onCreateView(parent, name, context, attrs);
        } else if (mFactory != null) {
            view = mFactory.onCreateView(name, context, attrs);
        } else {
            view = null;
        }
        if (view == null && mPrivateFactory != null) {
            view = mPrivateFactory.onCreateView(parent, name, context, attrs);
        }
        if (view == null) {
            final Object lastContext = mConstructorArgs[0];
            mConstructorArgs[0] = context;
            try {
                if (-1 == name.indexOf('.')) {
                    view = onCreateView(parent, name, attrs);
                } else {
                    view = createView(name, null, attrs);
                }
            } finally {
                mConstructorArgs[0] = lastContext;
            }
        }
        return view;
    } catch (InflateException e) {
        throw e;
    } 
    // 省略若干源代碼....
}

代碼查看到這裏終於看到了開頭所說的Factory這個東西,上面代碼最終通過調用onCreateView()來創建view;所以我們只需要對LayoutInflater設置一個Factory即可。

先來看看設置setFactory()的方法
// LayoutInflater.java
public void setFactory(Factory factory) {
    if (mFactorySet) {
        throw new IllegalStateException("A factory has already been set on this LayoutInflater");
    }
    if (factory == null) {
        throw new NullPointerException("Given factory can not be null");
    }
    mFactorySet = true;
    if (mFactory == null) {
        mFactory = factory;
    } else {
        mFactory = new FactoryMerger(factory, null, mFactory, mFactory2);
    }
}
  • 可以很清楚的看到,如果我們調用了這個方法那麼肯定會拋出一個異常IllegalStateException ,A factory has already been set on this LayoutInflater,所以設置之前我們需要通過反射將mFactorySet這個變量置爲false

需要注意的一點:

既然是干預View的加載創建,那肯定設置Factory需要在LayoutInflater實例創建之後,在加載創建View之前;而Activity是通過setContentView()加載View所以設置Factory需要在setContentView()之前;這裏可以通過Application設置Activity的生命週期監聽器,即registerActivityLifecycleCallbacks()

上面bb了一堆現在來上代碼了

public class App extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
            @Override
            public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
				setFactory(activity);
            }

            @Override
            public void onActivityStarted(Activity activity) {
            }

            @Override
            public void onActivityResumed(Activity activity) {
            }

            @Override
            public void onActivityPaused(Activity activity) {
            }

            @Override
            public void onActivityStopped(Activity activity) {
            }

            @Override
            public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
            }

            @Override
            public void onActivityDestroyed(Activity activity) {
            }
        });
    }
}
  • 創建SkinFactory.java
public final class SkinFactory implements LayoutInflater.Factory2 {
    private static final String TAG = "SkinFactory";
    private static final String[] classPrefixList = {"android.view.", "android.widget.", "android.webkit."};
    private static final String NAME_SPACE = "http://schemas.android.com/apk/azhon-skin";
    private static final String ATTRIBUTE = "enable";

    @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
     
        //找到佈局使用屬性(skin:enable="true")標記需要換膚的view
        boolean isSkinView = attrs.getAttributeBooleanValue(NAME_SPACE, ATTRIBUTE, false);
        //如果不是換膚的View就直接不處理
        if (!isSkinView) return null;
        View view = null;
        //name不包含.的說明是系統的控件
        if (-1 == name.indexOf('.')) {
            for (String prefix : classPrefixList) {
                view = createView(name, prefix, context, attrs);
                if (view != null) break;
            }
        } else {
            view = createView(name, null, context, attrs);
        }
        LogUtil.d(TAG, "onCreateView: 加載換膚View成功..." + view);
        return view;
    }


    /**
     * 創建系統自帶View
     */
    private View createView(String name, String prefix, Context context, AttributeSet attrs) {
        View view = null;
        try {
            view = LayoutInflater.from(context).createView(name, prefix, attrs);
        } catch (ClassNotFoundException e) {
            //
        }
        return view;
    }

    @Override
    public View onCreateView(String name, Context context, AttributeSet attrs) {
        return null;
    }
}
  • 設置Factory
/**
 * 設置佈局解析Factory
 * 需要將LayoutInflater的mFactorySet變量設置爲false
 */
private void setFactory(Activity activity) {
    try {
        LayoutInflater inflater = activity.getLayoutInflater();
        Field field = LayoutInflater.class.getDeclaredField("mFactorySet");
        field.setAccessible(true);
        field.setBoolean(inflater, false);
        //設置自己的Factory
        LayoutInflaterCompat.setFactory2(inflater, new SkinFactory());
    } catch (Exception e) {
        e.printStackTrace();
    }
}

在SkinFactory#onCreateView()中就可以獲取到我們標記的View了,這裏需要保存換膚的View,需要替換的屬性和屬性的值

三、創建一個Library項目製作皮膚包資源

  • app默認的顏色資源
    在這裏插入圖片描述
  • 對應的皮膚包如下:
    在這裏插入圖片描述
  • 作爲皮膚包只需要res目錄可以將java的目錄代碼全部刪除
  • 皮膚包中定義的資源名稱必須與主app定義的一模一樣
  • 然後通過AS的菜單——>Build——>Build Bundle(s) / APK(s)——> Build APK(s)就可以打包出來了

四、有了皮膚包資源就可以創建Resources對象拿到res/下的所有資源

  • 創建Resources對象
/**
 * 創建皮膚包的Resources
 *
 * @param path 皮膚包路徑
 */
public void createResources(Context context, String path) {
    try {
        AssetManager assetManager = AssetManager.class.newInstance();
        Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
        addAssetPath.invoke(assetManager, path);
        Resources resources = context.getResources();
        //創建對象
        Resources skinResources = new Resources(assetManager, resources.getDisplayMetrics(), resources.getConfiguration());
        //獲取皮膚包(也就是apk)的包名
        PackageManager packageManager = context.getPackageManager();
        PackageInfo packageInfo = packageManager.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES);
        String skinPackageName = packageInfo.packageName;
    } catch (Exception e) {
        e.printStackTrace();
    }
}
  • path 就是皮膚包路徑了/sdcard/Android/data/com.azhon.dynamicskin/cache/dark.skin
  • 通過PackageManager獲取皮膚包的包名,包名在獲取皮膚包內的資源時會用到

五、加載皮膚包內的資源,下面通過一個示例來講解

  • 我們需要替換這個TextView的background,textColor這兩個屬性
<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:background="@color/bg_1"
    android:text="我是一個TextView"
    android:textColor="@color/title_1"
    android:textSize="16sp"
    skin:enable="true" />

在自定義的SkinFactory中就可以獲取每一個屬性和屬性對應的值,如下:
在這裏插入圖片描述
這裏的@開頭值後面的數字就是res下的資源對應的Id(也是就是R文件的Id)

先介紹一個重要的(api)方法

int resId = resources.getIdentifier(String name, String defType, String defPackage);
第一個參數:資源的名字,例如:bg_1、titile_1
第二個參數:資源類型,例如:drawable、color、string
第三個參數:resources資源對應的包名

根據資源id加載皮膚包內對應的資源

  • 封裝的方法
/**
 * 根據資源Id獲取資源的名稱
 * @param resources 	app自身的資源對象
 * @param skinResources 皮膚包創建的資源對象
 * @param id            當前使用的資源id
 */
public static int getResourcesIdByName(Resources resources,Resources skinResources, String packageName, int id) {
    String[] res = getResourcesById(resources, id);
    //使用皮膚包創建的Resources加載資源
    return skinResources.getIdentifier(res[0], res[1], packageName);
}


/**
 * 根據資源Id獲取資源的名稱
 *
 * @param id 資源id
 * @return 資源名稱
 */
public static String[] getResourcesById(Resources resources, int id) {
    String entryName = resources.getResourceEntryName(id);
    String typeName = resources.getResourceTypeName(id);
    return new String[]{entryName, typeName};
}
  • 獲取對應皮膚包內的資源id(2130968664就是獲取到的資源id)
int skinResId = getResourcesIdByName(context.getResources(),skinResources,skinPackageName,2130968664);
  • 獲取到了資源的id,但是這個值是不能直接使用的需要在進一步操作
  • 上面通過getResourcesById()這個方法知道了這個資源id是屬於color類型的了,所以只要在調用一次getColor即可
int color = skinResources.getColor(skinResId);

通過上面幾步就成功的拿到了皮膚包內對應的資源,最後就只要調用TextView的setTextColor(color)就可以成功的替換文字的顏色了,同理替換background也是一樣的。

  • Resources也還提供了許多其它的方法:
    在這裏插入圖片描述

Demo示例下載地址

需要將項目根目錄的 dark.skin 文件拷貝至/sdcard/Android/data/com.azhon.dynamicskin/cache/目錄下

六、總結

  • 干預View的加載創建,Factory的原理和使用
  • 對一個apk包創建對應的Resources對象AssetManagerPackageManager的使用
  • 加載apk包內的資源,Resources的使用
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章