對於APP的換膚,曾經有一個公司的APP對於“某個”界面有換膚的需求,當時的做法是將換膚的規則定義成配置文件由服務器動態下發,然后根據配置文件的解析再來對界面中的元素進行換膚【如背景、字體顏色、大小之類的】,但是這種方式其實是很麻煩的,當時只是針對一個界面有這個換膚的需求,那如果是整個APP中的不同界面都要進行換膚呢?可想這種方式要實現起來就不知道有多復雜,XML解析也是挺浪費性能的;另外還有一個項目只是有一個簡單的換膚需求,當時的做法應該是人人都能想到的,我貼一下當時的代碼:
然后用的時候:
也就是由服務器下發一個皮膚類型的字段,然后本地根據這個類型再到app本地取不同的圖片,是不是非常之簡單粗暴,當時也沒想到更好的方式所以就這么實現了,當然效果沒啥問題,但是有一個很大的問題就是包體會隨着換膚元素的增加而增加,而且也不是特別靈活。
以上是在自己職業生涯中遇到換膚需求具體實現的兩個場景,不過今天要學的換膚肯定不是這兩種方式了,是一種比較靈活,也不是每個人都能有思路想出來的一種“黑科技【以我目前的能力而言認為它就是黑科技~~】”,而且也不會增加包的大小,下面一點點來剖析它。
網易雲音樂換膚效果分析:
咱們要實現的技術效果跟網易雲音樂的功能差不多,先來看一下它的效果,有兩種類型,一種是整個APP換膚,還一種是黑天和白天模式,如下:
APP換膚:
上面的效果湊合着看,可以手機上下一個體驗一下,反正就是使用一套皮膚之后整個界面的樣式都變了,但是大體的布局是一樣的。
黑白天模式:
注意,這種換膚功能對於Android系統有一個最低的要求,必須是5.0以上系統,因為在5.0以下是裝不上的,反編譯了一下雲音樂的apk確實是:
為啥?其實是在換膚時會用到這段代碼:
DEMO最終效果演示:
嗯,對於上面效果的實現原理是不是還挺值得探究的呢?下面先來貼一下最終咱們要實現的效果,當然界面是簡陋版,但是足以道出上面效果的原理了,如下:
換膚:
可以看到在app不重啟的情況下就實現了整個app的換膚,包含狀態欄和底部導航欄,其效果其實跟網易雲音樂的差不多。
日夜模式切換:
可以看到切換之后整個的色調都變化了,當然它的實現跟換膚的手法是不一樣的,待之后完整實現之后就曉得了。
手寫網易雲可動態替換的換膚框架:
上面已經展現了對於咱們最終要實現的效果了,下面則從0開始一步步來實現它~~
界面框架搭建:
關於界面的搭建這個不是重點,也比較簡單,沒啥可說的,直接開擼既可。
Splash界面:
styles.xml:
<resources> <!-- Base application theme. --> <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar"> <!-- Customize your theme here. --> <item name="colorPrimary">@color/colorPrimary</item> <item name="colorPrimaryDark">@color/colorPrimaryDark</item> <item name="colorAccent">@color/colorAccent</item> </style> <style name="SplashTheme" parent="Theme.AppCompat.Light.NoActionBar"> <item name="android:windowActionBar">false</item> <item name="android:windowFullscreen">true</item> <item name="android:windowBackground">@drawable/p_login_bg</item> </style> </resources>
其中用到了一張背景圖:
【說明】:附件地址為:https://files.cnblogs.com/files/webor2006/p_login_bg.jpg.zip
主界面:
布局文件:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/t_window_bg" android:orientation="vertical"> <Button android:layout_width="match_parent" android:layout_height="wrap_content" android:onClick="skinSelect" android:text="個性換膚" tools:ignore="MissingPrefix" /> <com.android.changskin.widget.MyTabLayout android:id="@+id/tabLayout" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" app:tabIndicatorColor="@color/tabSelectedTextColor" app:tabTextColor="@color/tab_selector" /> <androidx.viewpager.widget.ViewPager android:id="@+id/viewPager" android:layout_width="match_parent" android:layout_height="match_parent" /> </LinearLayout>
其中標紅的有一個背景資源:
一個標簽控件:
public class MyTabLayout extends TabLayout { int tabIndicatorColorResId; int tabTextColorResId; public MyTabLayout(Context context) { this(context, null, 0); } public MyTabLayout(Context context, AttributeSet attrs) { this(context, attrs, 0); } public MyTabLayout(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabLayout, defStyleAttr, 0); tabIndicatorColorResId = a.getResourceId(R.styleable.TabLayout_tabIndicatorColor, 0); tabTextColorResId = a.getResourceId(R.styleable.TabLayout_tabTextColor, 0); a.recycle(); } }
其中用到了兩個顏色:
因為要對它進行換膚,所以這里采用引用的色值的方式,而不是寫死的:
<?xml version="1.0" encoding="utf-8"?> <resources> <!--toolBar--> <color name="colorPrimary">#ffce3d3a</color> <color name="colorSkinText">#ffce3d3a</color> <!--狀態欄(style同時設置底部欄顏色)--> <color name="colorPrimaryDark">#ffce3d3a</color> <!-- 文字正常主色 --> <color name="colorAccent">#1f1f1f</color> <color name="tabSelectedTextColor">#ffce3d3a</color> </resources>
tab_selector.xml:
<?xml version="1.0" encoding="utf-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:color="@color/tabSelectedTextColor" android:state_selected="true" /> <item android:color="@color/colorAccent" /> </selector>
其中Adapter:
public class MyFragmentPagerAdapter extends FragmentPagerAdapter { private List<String> mTitles; private List<Fragment> mFragments; public MyFragmentPagerAdapter(FragmentManager fragmentManager, List<Fragment> fragments, List<String> titles) { super(fragmentManager); mFragments = fragments; mTitles = titles; } @Override public Fragment getItem(int position) { return mFragments.get(position); } @Override public int getCount() { return mFragments.size(); } @Override public CharSequence getPageTitle(int position) { return mTitles.get(position); } }
其中涉及到三個很簡單的Fragemnt:
MusicFragment:
public class MusicFragment extends Fragment { private View mView; private RecyclerView mRelView; @Nullable @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { mView = inflater.inflate(R.layout.fragment_music, container, false); mRelView = (RecyclerView) mView.findViewById(R.id.rel_view); //設置布局管理器 mRelView.setLayoutManager(new LinearLayoutManager(getContext())); GirlAdapter girlAdapter = new GirlAdapter(); mRelView.setAdapter(girlAdapter); return mView; } @Override public LayoutInflater onGetLayoutInflater(Bundle savedInstanceState) { return super.onGetLayoutInflater(savedInstanceState); } }
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" android:orientation="vertical"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/rel_view" android:layout_width="match_parent" android:layout_height="wrap_content" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="音樂" android:textColor="@color/colorAccent" android:textSize="22sp" /> </LinearLayout>
RadioFragment:
public class RadioFragment extends Fragment { @Nullable @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_radio, container, false); } }
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" android:orientation="vertical"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="電台" android:textColor="@color/colorAccent" android:textSize="22sp" /> </LinearLayout>
VideoFragment:
public class VideoFragment extends Fragment { @Nullable @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_video, container, false); } }
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" android:orientation="vertical"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="視頻" android:textColor="@color/colorAccent" android:textSize="22sp" /> </LinearLayout>
此時運行界面如下:
換膚操作界面:
public class SkinActivity extends Activity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_skin); } public void change(View view) { //TODO Toast.makeText(this, "換膚", Toast.LENGTH_SHORT).show(); } public void restore(View view) { //TODO Toast.makeText(this, "還原", Toast.LENGTH_SHORT).show(); } /** * 夜間模式 */ public void night(View view) { //TODO Toast.makeText(this, "夜間模式", Toast.LENGTH_SHORT).show(); } /** * 日間模式 */ public void day(View view) { //TODO Toast.makeText(this, "日間模式", Toast.LENGTH_SHORT).show(); } }
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/t_window_bg" android:orientation="vertical"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal"> <Button android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:onClick="change" android:text="換膚" /> <Button android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:onClick="restore" android:text="還原" /> </LinearLayout> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal"> <Button android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:onClick="day" android:text="日間" /> <Button android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:onClick="night" android:text="夜間" /> </LinearLayout> <!--測試換膚==>自定義View--> <com.android.changskin.widget.CircleView android:layout_width="32dp" android:layout_height="32dp" android:layout_marginTop="50dp" android:background="@color/colorAccent" app:corcleColor="@color/colorAccent" /> <!--測試TextView--> <Button android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:gravity="center" android:text="測試文字顏色與selector換膚" android:textColor="@color/selector_color_test" android:textSize="22sp" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:background="@color/colorPrimary" android:drawableLeft="@drawable/text_drawable_left" android:drawablePadding="8dp" android:gravity="center_vertical" android:text="測試TextView drawableLeft" android:textColor="@color/colorAccent" android:typeface="normal" /> </LinearLayout>
其中有個自定義的View:
public class CircleView extends View { private AttributeSet attrs; //畫筆 private Paint mTextPain; //半徑 private int radius; private int corcleColorResId; public CircleView(Context context) { this(context, null, 0); } public CircleView(Context context, @Nullable AttributeSet attrs) { this(context, attrs, 0); } public CircleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); this.attrs = attrs; TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleView); corcleColorResId = typedArray.getResourceId(R.styleable.CircleView_corcleColor, 0); typedArray.recycle(); mTextPain = new Paint(); mTextPain.setColor(getResources().getColor(corcleColorResId)); //開啟抗鋸齒,平滑文字和圓弧的邊緣 mTextPain.setAntiAlias(true); //設置文本位於相對於原點的中間 mTextPain.setTextAlign(Paint.Align.CENTER); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); //獲取寬度一半 int width = getWidth() / 2; //獲取高度一半 int height = getHeight() / 2; //設置半徑為寬或者高的最小值 radius = Math.min(width, height); //利用canvas畫一個圓 canvas.drawCircle(width, height, radius, mTextPain); } public void setCorcleColor(@ColorInt int color) { mTextPain.setColor(color); invalidate(); } }
selector_color_test.xml:
<?xml version="1.0" encoding="utf-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:color="@color/colorSkinText" /> </selector>
【說明】:附件地址為:https://files.cnblogs.com/files/webor2006/text_drawable_left.png.zip
此時界面為:
好,以上是換膚前的框架實現,下面則一點點開啟換膚功能的實現。
換膚實現:
思路整理:
在開篇也說了,這種換膚方式跟平常咱們想到的不太一樣,也不是人人能想到的,所以下面先來分析一下源碼,然后思路就是從分析之后打開的,從哪分析起呢?
很熟悉吧,往里跟一下:
看它的具體實現:
這個布局加載器我們在平常也經常用,看一下它的具體細節:
具體的解析細節就不看了,主要是看咱們想要看的,它會找XML中結點一個個進行解析,比如:
解析到了這個Button結點,那它是如何最終變成View對象的呢?下面大致瞅一下:
然后再看一下這個創建的細節:
而如果說通過工廠沒有創建成功View,而有可能是自定義的View,此時則會往下走:
而對於系統的View的創建跟進去瞅一下,看是如何來創建View的:
看到木有,所有系統所有的View的包名都是以android.view開頭的,所以手動加一個全類名的全綴,而不管是系統View和自定義的View最終都會執行這個createView方法,下面瞅一下:
當然這里有緩存的處理以提高性能,最終則通過反射來創建View:
至此整個View就實例化了,那分析這個流程跟咱們換膚功能的實現有啥關系呢?是不是要達到通用換膚的效果則必須要我們創建View之前把所有相關的樣式給准備好了?必須是這樣的【當然如果不采用這種靈活的換膚比如開篇我所遇到的那兩種方式的話另說】,所以接下來咱們就得想辦法自己來接管這個View的創建而非交由系統來,這點比較好辦,通過上面的工廠既可以達成,如下:
上面標紅的工廠是可以進行用戶手動設置的:
而如果要換膚通過自己來創建View的話則就需要有一個采集控件的過程,也就是需要對要換膚的控件進行采集出來,具體怎么采集這里先不用管,先明白一個實現的思路,下面用圖來表述一下:
開始實現:
設置自定義布局工廠:
接下來正式進入驚險又刺激的擼碼環節。。這里先來新建一個皮膚管理類,在Application中需要進行初始化一下:
public class SkinManager { private static SkinManager instance; private Application application; public static void init(Application application) { synchronized (SkinManager.class) { if (null == instance) { instance = new SkinManager(application); } } } private SkinManager(Application application) { this.application = application; } public static SkinManager getInstance() { return instance; } }
好,我們知道是要想辦法來設置一個工廠來動態創建View:
而對於LayoutInflater的創建我們可以這樣:
也可以在具體Activity中來創建:
也就是對於context可以是Application也可以是Activity,這倆是不一樣的,生命周期不一樣,對於具體界面的創建應該是要傳當前的Activity的,那么問題來了,我們要想通用的來處里所有Activity的界面,很顯然這個LayoutInflater是要在Application中,但是!!它的構造需要傳一個指定Activity的Context,那不相互矛盾了么?解決辦法其實也很簡單,可以在Application中來監聽Activity的生命周期既可,如下:
public class SkinActivityLifecycle implements Application.ActivityLifecycleCallbacks { @Override public void onActivityCreated(Activity activity, Bundle savedInstanceState) { LayoutInflater layoutInflater = LayoutInflater.from(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) { } }
好,此時咱們就可以設置咱們的布局加載器的工廠了,所以創建一個工廠:
public class SkinLayoutFactory implements LayoutInflater.Factory2 { @Nullable @Override public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) { return null; } @Nullable @Override public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) { return null; } }
好,接下來則需要我們自己來采集控件了。
采集換膚控件:
先來實現它里面的onCreateView()方法:
其它里面的代碼可以效仿一下之前分析的源碼,如下:
所以咱們來實現一下:
接着來處理系統的控件:
好,接下來則來具體看下如何來創建View了,這里可以繼續來校仿LayoutInflater:
總的來說是通過反射來創建View的,所以咱們也來寫一樣:
比較容易理解, 但是如果通過反射獲取不到View,比如自定義的View,則需要這樣處理:
既然拿到了具體的控件,接下來則可以拿到控件里面的屬性了,這里又會有一個過濾邏輯,得過濾出需要換膚的屬性,關於這塊邏輯的處理由於篇幅太長了,放下一篇再繼續。