Android換膚實現技術思路總結

Mark Zhai發表於2015-09-12

背景

縱觀現在各種Android app,其換膚需求可以歸為

  • 白天/黑夜主題切換(或者別的名字,通常2套),如同花順/自選股/天天動聽等,UI表現為一個switcher。
  • 多種主題切換,通常為會員特權,如QQ/QQ空間。

對於第一種來說,目測應該是直接通過本地theme來做的,即所有圖片/顏色的資源都在apk裡面打包了。而對於第二種,則相對複雜一些,由於作為一種線上服務,可能上架新皮膚,且那麼多皮膚包放在apk裡面實在太佔體積了,所以皮膚資源會在選擇後再進行下載,也就不能直接使用android的那套theme。

技術方案

內部資源載入方案和動態下載資源下載兩種。動態下載可以稱為一種黑科技了,因為往往需要hack系統的一些方法,所以在部分機型和新的API上有時候可能有坑,但相對好處則很多

  • 圖片/色值等資源由於是後臺下發的,可以隨時更新
  • APK體積減小
  • 對應用開發者來說,換膚幾乎是透明的,不需要關心有幾套皮膚
  • 可以作為增值服務賣錢!!

內部資源載入方案

內部資源載入都是通過android本身那套theme來做的,相對業務開發來說工作量更大(需要定義attr和theme),不同方案類似地都是在BaseActivity裡面做setTheme,差別主要在解決以下2個問題的策略:

  • setTheme後如何實時重新整理,而不用重新建立頁面(尤其是listview裡面的item)。
  • 哪些view需要重新整理,重新整理什麼(背景?字型顏色?ImageView的src?)。

自定義view

MultipleTheme

做自定義view是為了在setTheme後會去立即重新整理,更新頁面UI對應資源(如TextView替換背景圖和文字顏色),在上述專案中,則是通過對rootView進行遍歷,對所有實現了ColorUiInterface的view/viewgroup進行setTheme操作來實現即使重新整理的。

顯然這樣太重了,需要把應用內的各種view/viewgroup進行替換。

手動繫結view和要改變的資源型別

Colorful

這個…我們看看用法吧…

ViewGroupSetter listViewSetter = new ViewGroupSetter(mNewsListView);
// 繫結ListView的Item View中的news_title檢視,在換膚時修改它的text_color屬性
listViewSetter.childViewTextColor(R.id.news_title, R.attr.text_color);
// 構建Colorful物件來繫結View與屬性的物件關係
mColorful = new Colorful.Builder(this)
  .backgroundDrawable(R.id.root_view, R.attr.root_view_bg)
  // 設定view的背景圖片
  .backgroundColor(R.id.change_btn, R.attr.btn_bg)
  // 設定背景色
  .textColor(R.id.textview, R.attr.text_color)
  .setter(listViewSetter) // 手動設定setter
  .create(); // 設定文字顏色

我就是想換個皮膚,還得在activity裡自己去設定要改變哪個view的什麼屬性,對應哪個attribute?是不是成本太高了?而且activity的邏輯也很容易被弄得亂七八糟。

動態資源載入方案

resource替換

開源專案可參照 Android-Skin-Loader

即覆蓋application的getResource方法,優先載入本地皮膚包資料夾下的資源包,對於效能問題,可以通過attribute或者資源名稱規範(如需要換膚則用skin_開頭)來優化,從而不對不換膚的資源進行額外開銷。

可以重點關注該專案中的SkinInflaterFactory和SkinManager(實現了自己的getColor、getDrawable方法)。

不過由於Android 5.1原始碼裡,getDrawable方法的實現被修改了,所以會導致無法跟膚的問題(其實是loadDrawable被修改了,連引數都改了,類似的內部API大改在5.1上還很多)。

4.4的原始碼中Resources.java:

public Drawable getDrawable(int id) throws NotFoundException {
  TypedValue value;
  synchronized (mAccessLock) {
    value = mTmpValue;
    if (value == null) {
      value = new TypedValue();
    } else {
      mTmpValue = null;
    }
    getValue(id, value, true);
  }
  // 實際資源通過loadDrawable方法載入
  Drawable res = loadDrawable(value, id);
  synchronized (mAccessLock) {
    if (mTmpValue == null) {
      mTmpValue = value;
    }
  }
  return res;
}
// loadDrawable會去preload的LongSparseArray裡面查詢
/*package*/ Drawable loadDrawable(TypedValue value, int id)
    throws NotFoundException {
  if (TRACE_FOR_PRELOAD) {
    // Log only framework resources
    if ((id >>> 24) == 0x1) {
      final String name = getResourceName(id);
      if (name != null) android.util.Log.d("PreloadDrawable", name);
    }
  }
  boolean isColorDrawable = false;
  if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT &&
      value.type <= TypedValue.TYPE_LAST_COLOR_INT) {
    isColorDrawable = true;
  }
  final long key = isColorDrawable ? value.data :
      (((long) value.assetCookie) << 32) | value.data;
  Drawable dr = getCachedDrawable(isColorDrawable ? mColorDrawableCache : mDrawableCache, key);
  if (dr != null) {
    return dr;
  }
  ...
  ...
  return dr;
}

而5.1程式碼裡Resources.java:

// 可以看到,方法引數裡面加上了Theme
public Drawable getDrawable(int id, @Nullable Theme theme) throws NotFoundException {
  TypedValue value;
  synchronized (mAccessLock) {
    value = mTmpValue;
    if (value == null) {
      value = new TypedValue();
    } else {
      mTmpValue = null;
    }
    getValue(id, value, true);
  }
  final Drawable res = loadDrawable(value, id, theme);
  synchronized (mAccessLock) {
    if (mTmpValue == null) {
      mTmpValue = value;
    }
  }
  return res;
}
/*package*/ Drawable loadDrawable(TypedValue value, int id, Theme theme) throws NotFoundException {
  if (TRACE_FOR_PRELOAD) {
    // Log only framework resources
    if ((id >>> 24) == 0x1) {
      final String name = getResourceName(id);
      if (name != null) {
        Log.d("PreloadDrawable", name);
      }
    }
  }
  final boolean isColorDrawable;
  final ArrayMap<String, LongSparseArray<WeakReference<ConstantState>>> caches;
  final long key;
  if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT
      && value.type <= TypedValue.TYPE_LAST_COLOR_INT) {
    isColorDrawable = true;
    caches = mColorDrawableCache;
    key = value.data;
  } else {
    isColorDrawable = false;
    caches = mDrawableCache;
    key = (((long) value.assetCookie) << 32) | value.data;
  }
  // First, check whether we have a cached version of this drawable
  // that was inflated against the specified theme.
  if (!mPreloading) {
    final Drawable cachedDrawable = getCachedDrawable(caches, key, theme);
    if (cachedDrawable != null) {
      return cachedDrawable;
    }
  }

方法名字都改了

Hack Resources internally

黑科技方法,直接對Resources進行hack,Resources.java:

// Information about preloaded resources.  Note that they are not
// protected by a lock, because while preloading in zygote we are all
// single-threaded, and after that these are immutable.
private static final LongSparseArray<Drawable.ConstantState>[] sPreloadedDrawables;
private static final LongSparseArray<Drawable.ConstantState> sPreloadedColorDrawables
        = new LongSparseArray<Drawable.ConstantState>();
private static final LongSparseArray<ColorStateList> sPreloadedColorStateLists
        = new LongSparseArray<ColorStateList>();

直接對Resources裡面的這三個LongSparseArray進行替換,由於apk執行時的資源都是從這三個陣列裡面載入的,所以只要採用interceptor模式:

public class DrawablePreloadInterceptor extends LongSparseArray<Drawable.ConstantState>

自己實現一個LongSparseArray,並通過反射set回去,就能實現換膚,具體getDrawable等方法裡是怎麼取preload陣列的,可以自己看 Resources 的原始碼。

等等,就這麼簡單?,NONO,少年你太天真了,怎麼去載入xml,9patch的padding怎麼更新,怎麼打包/載入自定義的皮膚包,drawable的狀態怎麼重新整理,等等。這些都是你需要考慮的,在存在外掛的app中,還需要考慮是否會互相覆蓋resource id的問題,進而需要修改apt,把resource id按位放在2個range。

手Q和獨立版QQ空間使用的是這種方案,效果挺好。

總結

儘管動態載入方案比較黑科技,可能因為系統API的更改而出問題,但相對來所

好處有

  • 靈活性高,後臺可以隨時更新皮膚包
  • 相對透明,開發者幾乎不用關心有幾套皮膚,不用去定義各種theme和attr,甚至連皮膚包的打包都- – 可以交給設計或者專門的同學
  • apk體積節省存在的問題
  • 沒有完善的開源專案,如果我們採用動態載入的第二種方案,需要的專案功能包括:
  • 自定義皮膚包結構
  • 換膚引擎,載入皮膚包資源並load,實時重新整理。
  • 皮膚包打包工具
  • 對各種rom的相容

如果有這麼一個專案的話,就一勞永逸了,有興趣的同學可以聯絡一下,大家一起搞一搞。

內部載入方案大同小異,主要解決的都是即時重新整理的問題,然而從目前的一些開源專案來看,仍然沒有特別簡便的方案。讓我選的話,我寧願讓介面重新建立,比如重啟activity,或者remove所有view再新增回來。

相關文章