Android 源碼系列之從源碼的角度深入理解LayoutInflater.Factory之主題切換(中)

        轉載請註明出處:http://blog.csdn.net/llew2011/article/details/51287391

        在上篇文章Android 源碼系列之<四>從源碼的角度深入理解LayoutInflater.Factory之主題切換(上)中我們主要講解了LayoutInflater渲染xml佈局文件的流程,文中講到如果在渲染之前爲LayoutInflater設置了Factory,那麼在渲染每一個View視圖時都會調用Factory的onCreateView()方法,因此可以拿onCreateView()方法做切入口實現主題切換功能。如果你不清楚LayoutInflater的渲染流程,請點擊這裏。今天我們就從實戰出發來實現自己的主題切換功能。

        既然主題切換是依賴Factory的,那麼就需要定義自己的Factory了,自定義Factory其實就是實現系統的Factory接口,代碼如下:

public class SkinFactory implements Factory {

	@Override
	public View onCreateView(String name, Context context, AttributeSet attrs) {
		
		Log.e("SkinFactory", "==============start==============");
		
		int attrCounts = attrs.getAttributeCount();
		for(int i = 0; i < attrCounts; i++) {
			String attrName = attrs.getAttributeName(i);
			String attrValue = attrs.getAttributeValue(i);
			Log.e("SkinFactory", "attrName = " + attrName + "       attrValue = " + attrValue);
		}
		
		Log.e("SkinFactory", "==============end==============");
		
		return null;
	}
}
        自定義SkinFactory什麼都沒有做,僅僅在onCreateView()方法中循環打印了attrs包含的屬性名和對應的屬性值,然後返回了null。創建完SkinFactory之後就是如何使用它了,上篇文章中我們講過在Activity中可以通過getLayoutInflater()方法獲取LayoutInflater實例對象,獲取到該對象之後就可以給該其賦值Factory了,代碼如下:
public class MainActivity extends Activity {
	
	private LayoutInflater mInflater;
	private SkinFactory mFactory;
	
	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		mFactory = new SkinFactory();
		mInflater = getLayoutInflater();
		mInflater.setFactory(mFactory);
		
		setContentView(R.layout.activity_skin);
	}
}
        需要注意的是給Activity的LayoutInflater設置Factory時一定要在調用setContentView()方法之前,否則不起作用。設置好Factory之後,我們看看一下activity_skin.xml佈局文件是如何定義的,代碼如下:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/color_app_bg" >

    <TextView
        android:id="@+id/text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="Factory的小練習"
        android:textColor="@color/color_title_bar_text" />

</FrameLayout>
        佈局文件居中顯示了一個TextView,並且給TextView設置文本爲"Factory的小練習",運行一下程序,打印結果如下:

        這裏只貼出了TextView的打印數據,從打印出的數據可以發現如果屬性值是以@開頭就表示該屬性值是一個應用(以後可以通過@符號來判斷當前屬性是否是引用)。因爲我們可以在attrs中拿到View在佈局文件中定義的所有屬性,所以可以猜想:如果給View添加自定義屬性,在onCreateView()方法中通過解析這個自定義屬性就可以判別出要做主題切換的View了。這個猜想正不正確,我們來試驗一下。

        在values文件夾下創建attrs.xml屬性文件,定義屬性名爲enable,屬性值爲boolean類型(true表示需要主題切換,false表示不需要主題切換),代碼如下:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <attr name="enable" format="boolean" />
</resources>
        定義完屬性後,若要使用該屬性需要先申明命名空間,比如系統自帶的:xmlns:android="http://sckemas.android.com/apk/res/android",申明命名空間有兩種方法:xmlns:skin="http://schemas.android.com/apk/包名"或者是xmlns:skin="http://schemas.android.com/apk/res-auto"。我們採用第二種寫法,代碼如下:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:skin="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/color_app_bg" >

    <TextView
        android:id="@+id/text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="Factory的小練習"
        skin:enable="true"
        android:textColor="@color/color_title_bar_text" />

</FrameLayout>
        在activity_skin.xml佈局文件中給TextView添加了自定義的enable屬性並把值設爲true,添加完屬性後編譯器報錯提示說TextView沒有該屬性,只要手動清理一下就好了。然後運行代碼,打印結果如下:

        看到打印結果我們心裏好happy呀,採用給View添加自定義的屬性這種方式是OK的,接下來我們就可以根據該屬性區分出哪些View需要做主題切換了。做主題切換的前提是緩存那些需要做主題切換的View,但是View做主題切換可能需要更改背景,文字等。也就說一個View可能要更改多個屬性,那這個屬性就要求在不同的場景下對應不同的類型,所以可以抽象出代表屬性的類BaseAttr,BaseAttr類有屬性名,屬性值,屬性類型等成員變量,還要有一個抽象方法(該方法在不同的場景下有不同的實現,比如當前屬性爲background,那在BackgroundAttr實現中就應該是設置背景;若當前屬性爲textColor,那在TextColorAttr實現中就應該是設置文字顏色)。所以BaseAttr可以抽象如下:

public abstract class BaseAttr {

	public String attrName;
	public int attrValue;
	public String entryName;
	public String entryType;
	
	public abstract void apply(View view);
}

        定義好BaseAttr類之後就可以定義具體的實現類了,比如背景屬性類BackgroundAttr,字體顏色改變類TextColorAttr等,BackgroundAttr代碼如下:

public class BackgroundAttr extends BaseAttr {
	@Override
	public void apply(View view) {
		if(null != view) {
			view.setBackgroundXXX();
		}
	}
}

        抽象出屬性類BaseAttr之後我們還要考慮緩存View的問題,因爲一個View可能要對應多個BaseAttr,所以我們還要封裝一個類SkinView,該類表示一個View對應多個BaseAttr,它還要提供更新自己的方法,所以代碼如下:

public class SkinView {

	public View view;
	public List<BaseAttr> viewAttrs;
	
	public void apply() {
		if(null != view && null != viewAttrs) {
			for(BaseAttr attr : viewAttrs) {
				attr.apply(view);
			}
		}
	}
}
        抽象屬性類BaseAttr和SkinView定義完了,接下來就可以在SkinFactory中做緩存邏輯了,代碼如下:
public class SkinFactory implements Factory {

	private static final String DEFAULT_SCHEMA_NAME = "http://schemas.android.com/apk/res-auto";
	private static final String DEFAULT_ATTR_NAME = "enable";
	
	private List<SkinView> mSkinViews = new ArrayList<SkinView>();
	
	@Override
	public View onCreateView(String name, Context context, AttributeSet attrs) {
		View view = null;
		final boolean skinEnable = attrs.getAttributeBooleanValue(DEFAULT_SCHEMA_NAME, DEFAULT_ATTR_NAME, false);
		if(skinEnable) {
			view = createView(name, context, attrs);
			if(null != view) {
				parseAttrs(name, context, attrs, view);
			}
		}
		return view;
	}
	
	public final View createView(String name, Context context, AttributeSet attrs) {
		View view = null;
		if(-1 == name.indexOf('.')) {
			if("View".equalsIgnoreCase(name)) {
				view = createView(name, context, attrs, "android.view.");
			}
			if(null == view) {
				view = createView(name, context, attrs, "android.widget.");
			}
			if(null == view) {
				view = createView(name, context, attrs, "android.webkit.");
			}
		} else {
			view = createView(name, context, attrs, null);
		}
		return view;
	}
	
	View createView(String name, Context context, AttributeSet attrs, String prefix) {
		View view = null;
		try {
			view = LayoutInflater.from(context).createView(name, prefix, attrs);
		} catch (Exception e) {
		}
		return view;
	}
	
	private void parseAttrs(String name, Context context, AttributeSet attrs, View view) {
		int attrCount = attrs.getAttributeCount();
		final Resources temp = context.getResources();
		List<BaseAttr> viewAttrs = new ArrayList<BaseAttr>();
		for(int i = 0; i < attrCount; i++) {
			String attrName = attrs.getAttributeName(i);
			String attrValue = attrs.getAttributeValue(i);
			if(isSupportedAttr(attrName)) {
				if(attrValue.startsWith("@")) {
					int id = Integer.parseInt(attrValue.substring(1));
					String entryName = temp.getResourceEntryName(id);
					String entryType = temp.getResourceTypeName(id);
					
					BaseAttr viewAttr = createAttr(attrName, attrValue, id, entryName, entryType);
					if(null != viewAttr) {
						viewAttrs.add(viewAttr);
					}
				}
			}
		}
		
		if(viewAttrs.size() > 0) {
			SkinView skinView = new SkinView();
			skinView.view = view;
			skinView.viewAttrs = viewAttrs;
			mSkinViews.add(skinView);
		}
	}

	// attrName:textColor   attrValue:2130968576   entryName:common_bg_color   entryType:color
	private BaseAttr createAttr(String attrName, String attrValue, int id, String entryName, String entryType) {
		BaseAttr viewAttr = null;
		if("background".equalsIgnoreCase(attrName)) {
			viewAttr = new BackgroundAttr();
		} else if("textColor".equalsIgnoreCase(attrName)) {
			viewAttr = new TextColorAttr();
		}
		if(null != viewAttr) {
			viewAttr.attrName = attrName;
			viewAttr.attrValue = id;
			viewAttr.entryName = entryName;
			viewAttr.entryType = entryType;
		}
		return viewAttr;
	}

	private boolean isSupportedAttr(String attrName) {
		if("background".equalsIgnoreCase(attrName)) {
			return true;
		} else if("textColor".equalsIgnoreCase(attrName)) {
			return true;
		}
		return false;
	}

	public void applaySkin() {
		if(null != mSkinViews) {
			for(SkinView skinView : mSkinViews) {
				if(null != skinView.view) {
					skinView.apply();
				}
			}
		}
	}
}

        SkinFactory中定義了裝載SkinView類型的mSkinViews緩存集合,當解析到符合條件的View時就會緩存到該集合中。在onCreateView()方法中調用AttributeSet的getAttributeBooleanValue()方法檢測是否含有enable屬性,如果有enable屬性並且屬性值爲true時我們自己調用系統API來創建View,如果創建成功就解析該View,分別獲取其attrName,attrValue,entryName,entryType值取完之後創建對應的BaseAttr,然後加入緩存集合mSkinViews中,否則返回null。

        創建完SkinFactory之後還需要創建一個主題資源管理器SkinManager,主題切換就是通過該管理器來決定的。所以其主要有以下功能:實現讀取額外主題資源功能,恢復默認主題功能,更新主題功能等。

       先看一下如何讀取額外主題資源問題。做主題切換需要準備多套主題,這些主題其實就是一些圖片,顏色等。有了素材之後我們還要考慮如何提供給APP素材的形式,是直接提供一個Zip包文件還是說做成一個apk文件的形式提供給APP?如果提供Zip包接下來的處理是解壓該Zip包得到裏邊的素材然後解析讀取,理論上來說這種方式是可行的,但是操作起來有點複雜。所以我們採用apk的形式,若希望訪問素材apk中的資源如同在APP中訪問資源一樣,我們得獲取到素材apk的Resources實例,下面我直接提供一種通用的可以獲取apk的Resources實例代碼,代碼如下:

public final Resources getResources(Context context, String apkPath) {
	try {
		AssetManager assetManager = AssetManager.class.newInstance();
		Method addAssetPath = assetManager.getClass().getDeclaredMethod("addAssetPath", String.class);
		addAssetPath.setAccessible(true);
		addAssetPath.invoke(assetManager, apkPath);
		
		Resources r = context.getResources();
		Resources skinResources = new Resources(assetManager, r.getDisplayMetrics(), r.getConfiguration());
		return skinResources;
	} catch (Exception e) {
	}
	return null;
}

        這段代碼可以有效的獲取到apk中的Resources實例,然後通過該Resources實例訪問資源就如同我們在APP中直接訪問自己資源一般,如果你對Android的資源訪問機制很熟悉的話,很清楚這段代碼爲什麼要這麼寫。不清楚也沒關係,先暫時這麼用,我會在後續文章中從源碼的角度分析一下Android的資源訪問機制並解釋這麼寫的原因。

        好了,現在我們已經解決了訪問素材資源的問題,那接下來就是編寫我們的SkinManager類了,SkinManager類的功能是來加載素材資源文件的,在加載文件時可能有失敗的情況,所以需要給APP回調來通知加載資源的結果,我們定義接口ILoadListener,代碼如下:

public interface ILoadListener {
	void onStart();
	void onSuccess();
	void onFailure();
}
        ILoadListener接口有三個方法,分別表示資源開始加載的回調,加載成功後的回調和加載失敗後的回調。我們接着完成我們SkinManager代碼,如下所示:
public final class SkinManager {

	private static final Object mClock = new Object();
	private static SkinManager mInstance;
	
	private Context mContext;
	private Resources mResources;
	private String mSkinPkgName;
	
	private SkinManager() {
	}
	
	public static SkinManager getInstance() {
		if(null == mInstance) {
			synchronized (mClock) {
				if(null == mInstance) {
					mInstance = new SkinManager();
				}
			}
		}
		return mInstance;
	}
	
	public void init(Context context) {
		enableContext(context);
		mContext = context.getApplicationContext();
	}
	
	public void loadSkin(String skinPath) {
		loadSkin(skinPath, null);
	}
	
	public void loadSkin(final String skinPath, final ILoadListener listener) {
		enableContext(mContext);
		if(TextUtils.isEmpty(skinPath)) {
			return;
		}
		new AsyncTask<String, Void, Resources>() {
			@Override
			protected void onPreExecute() {
				if(null != listener) {
					listener.onStart();
				}
			}
			
			@Override
			protected Resources doInBackground(String... params) {
				if(null != params && params.length == 1) {
					String skinPath = params[0];
					File file = new File(skinPath);
					if(null != file && file.exists()) {
						PackageManager packageManager = mContext.getPackageManager();
						PackageInfo packageInfo = packageManager.getPackageArchiveInfo(skinPath, 1);
						if(null != packageInfo) {
							mSkinPkgName = packageInfo.packageName;
						}
						return getResources(mContext, skinPath);
					}
				}
				return null;
			}
			@Override
			protected void onPostExecute(Resources result) {
				if(null != result) {
					mResources = result;
					if(null != listener) {
						listener.onSuccess();
					}
				} else {
					if(null != listener) {
						listener.onFailure();
					}
				}
			}
		}.execute(skinPath);
	}
	
	public Resources getResources(Context context, String apkPath) {
		try {
			AssetManager assetManager = AssetManager.class.newInstance();
			Method addAssetPath = assetManager.getClass().getDeclaredMethod("addAssetPath", String.class);
			addAssetPath.setAccessible(true);
			addAssetPath.invoke(assetManager, apkPath);
			
			Resources r = context.getResources();
			Resources skinResources = new Resources(assetManager, r.getDisplayMetrics(), r.getConfiguration());
			return skinResources;
		} catch (Exception e) {
		}
		return null;
	}
	
	public void restoreDefaultSkin() {
		if(null != mResources) {
			mResources = null;
			mSkinPkgName = null;
		}
	}
	
	public int getColor(int id) {
		enableContext(mContext);
		Resources originResources = mContext.getResources();
		int originColor = originResources.getColor(id);
		if(null == mResources || TextUtils.isEmpty(mSkinPkgName)) {
			return originColor;
		}
		String entryName = mResources.getResourceEntryName(id);
		int resourceId = mResources.getIdentifier(entryName, "color", mSkinPkgName);
		try {
			return mResources.getColor(resourceId);
		} catch (Exception e) {
		}
		return originColor;
	}
	
	public Drawable getDrawable(int id) {
		enableContext(mContext);
		Resources originResources = mContext.getResources();
		Drawable originDrawable = originResources.getDrawable(id);
		if(null == mResources || TextUtils.isEmpty(mSkinPkgName)) {
			return originDrawable;
		}
		String entryName = mResources.getResourceEntryName(id);
		int resourceId = mResources.getIdentifier(entryName, "drawable", mSkinPkgName);
		try {
			return mResources.getDrawable(resourceId);
		} catch (Exception e) {
		}
		return originDrawable;
	}
	
	private void enableContext(Context context) {
		if(null == context) {
			throw new NullPointerException();
		}
	}
}

        SkinManager我們採用了單例模式保證應用中只有一個實例,在使用的時候需要先進行初始化操作否則會拋異常。SkinManager不僅定義了屬性mContext和mResources(mContext表示APP的運行上下文環境,mResources代表資源apk的Resources實例對象,如果爲空表示使用默認APP主題資源),而且它還對外提供了一系列方法,比如讀取資源的getColor()和getDrawable()方法,加載資源apk的方法loadSkin()等。

        現在主題切換的核心邏輯都有了,我們看一下程序包結構圖是怎樣的,切圖如下:

        主題切換的核心邏差不多已經然完成了,接下來就是要練習使用一下看看效果能不能成了,首先修改activity_skin.xml佈局文件,修改如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:skin="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/common_bg_color"
    android:orientation="vertical"
    skin:enable="true" >

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="65dp"
        android:background="@color/common_title_bg_color"
        skin:enable="true" >

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:text="主題切換標題"
            android:textColor="@color/common_title_text_color"
            android:textSize="18sp"
            skin:enable="true" />

        <Button
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="right|center_vertical"
            android:onClick="updateSkin"
            android:text="切換主題" />
    </FrameLayout>

</LinearLayout>
        在activity_skin.xml佈局文中給需要做主題切換的View節點添加了enable屬性並且設置其值爲true。接下來就是要做一個主題apk包了,做主題包的簡單方式就是新建一個工程,裏邊不添加Activity等,然後在資源文件夾下創建對應的資源等,需要注意的是資源文件名一定要和APP中的資源名一致。然後編譯打包成一個apk文件,這裏就不再演示了。打包完apk後我們導入到模擬器根目錄下,然後修改MainActivity,添加updateSkin()方法,代碼如下:
public void updateSkin(View view) {
	String skinPath = Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator + "skin.apk";
	SkinManager.getInstance().loadSkin(skinPath, new ILoadListener() {
		@Override
		public void onSuccess() {
			mFactory.applaySkin();
		}
		
		@Override
		public void onStart() {
		}
		
		@Override
		public void onFailure() {
		}
	});
}
        添加完updateSkin()方法之後,就可以實現切換主題了,爲了方便我直接把skin.apk文件直接導入了SD卡根目錄下,需要注意有的手機沒有外置存儲卡需要做個判斷,別忘了在配置文件添加文件的讀寫權限,然後運行程序,效果如下:

        好了,現在在當前頁面進行主題切換看起來是OK的,但是還存在不足,當頁面進行跳轉比如從A→B→C→D然後在D中進行主題切換,這時候ABC是沒有效果的,另外代碼的通用性也不強,所以在下篇文章Android 源碼系列之<六>從源碼的角度深入理解LayoutInflater.Factory之主題切換(下)處理這些問題,敬請期待...





發佈了37 篇原創文章 · 獲贊 87 · 訪問量 18萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章