Android 主题切换/换肤方案 研究(一) - 知乎

参考:

知乎和简书的夜间模式实现套路

对于Android日夜间模式实现的探讨

【Android】开发干货-技术分享之高仿QQ换肤SkinEngine实现

Android中插件开发篇之----应用换肤原理解析 (QQ空间)

Android换肤技术总结

Android 源码系列之<四>从源码的角度深入理解LayoutInflater.Factory之主题切换(上)

浅谈Android Support Library 23.2新增夜间模式主题

开源中国源码学习(五)——切换皮肤(日间模式和夜间模式)

新浪微博Android客户端夜间模式是如何实现的

Android主题切换方案总结


一直希望研究下android app主题换肤的优秀实现方案,做些分析记录。


下面主要是从源码角度来分析几款主流app的换肤方案,分析结果基于有限途径的分析,不一定完全正确,因为市场上的app混淆方面大多都做的非常好,但通过反编译,依旧可以看出很多有效信息。


1. 知乎

用过就知道,知乎app的夜间模式切换效果在目前市面上众多app中做的是相当好的,赶紧来分析。

反编译知乎app(用apktool反编译apk可以得到smali文件和资源文件等,用于分析资源文件,用smali2java反编译apk可以看到java源码,用于分析源码),知乎简直是个良心app啊,正如他的主旨一样 "与世界分享你的知识、经验和见解",知乎app竟然没有混淆啊!用好一点的反编译工具理论上应该可以完全反编译出来,当然实际上我反编译的还有些代码没有反编译成功。 继续分析...


可是源码不好找, 于是先看资源文件,在res\values目录下查看colors.xml文件:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="color_0d000000_26000000">#0d000000</color>
    <color name="color_1a000000_1affffff">#1a000000</color>
    <color name="color_1f000000_33ffffff">#1f000000</color>
    <color name="color_49000000_49ffffff">#49000000</color>
    <color name="color_4c000000_4cffffff">#4c000000</color>
    <color name="color_4d000000_2bffffff">#4d000000</color>
    <color name="color_4d000000_4cffffff">#4d000000</color>
    <color name="color_4d000000_4d000000">#4d000000</color>
    <color name="color_4d000000_54ffffff">#4d000000</color>
    <color name="color_4d000000_ff2e3e45">#4d000000</color>
    <color name="color_4d3d9bf5_4d3d9bf5">#4d3d9bf5</color>
    <color name="color_60000000_60ffffff">#60000000</color>

命名很有规律,但是初看就有个疑惑,为什么颜色名称包含有两个颜色值,并且颜色值和名称里的第一个颜色值一样,后来也是按着心中的猜想走,于是找到了res\values-night-v8目录下的colors.xml文件:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="color_0d000000_26000000">#26000000</color>
    <color name="color_1a000000_1affffff">#1affffff</color>
    <color name="color_1f000000_33ffffff">#33ffffff</color>
    <color name="color_49000000_49ffffff">#49ffffff</color>
    <color name="color_4c000000_4cffffff">#4cffffff</color>
    <color name="color_4d000000_2bffffff">#2bffffff</color>
    <color name="color_4d000000_4cffffff">#4cffffff</color>
    <color name="color_4d000000_4d000000">#4d000000</color>
    <color name="color_4d000000_54ffffff">#54ffffff</color>
    <color name="color_4d000000_ff2e3e45">#ff2e3e45</color>
    <color name="color_4d3d9bf5_4d3d9bf5">#4d3d9bf5</color>
    <color name="color_60000000_60ffffff">#60ffffff</color>

(注意上述两份颜色资源只截取了原colors.xml文件的一部分)


这一份资源文件里的颜色值和名称里的第二个颜色值一样,豁然开朗,于是基本路线就可以初步确定,知乎app也是采用在本地xml文件里配置两份颜色资源,然后根据当前的主题去取对应的颜色资源。而values-night-v8目录下的colors.xml就是在夜间模式下取的颜色资源。

同样,res目录下的color目录也有一个color-night-v8与之对应,drawable目录也有drawable-night-v8与之对应。


继续分析,在res\layout目录打开一个布局文件,这里打开的是fragment_live.xml,手动格式化了下:

<?xml version="1.0" encoding="utf-8"?>
<com.zhihu.android.base.widget.ZHRelativeLayout 
	android:id="@id/im_root" 
	android:background="@color/color_ffffffff_ff37474f" 
	android:layout_width="fill_parent" 
	android:layout_height="fill_parent"
    xmlns:android="http://schemas.android.com/apk/res/android">
    <com.zhihu.android.app.ui.widget.FixRefreshLayout 
		android:id="@id/swipe_refresh_layout" 
		android:layout_width="fill_parent" 
		android:layout_height="wrap_content" 
		android:layout_marginBottom="-2.0dip" 
		android:layout_above="@id/live_bottom_bar_container" 
		android:layout_below="@id/system_bar">
        <com.zhihu.android.base.widget.ZHRecyclerView 
			android:id="@id/recycler_view" 
			android:scrollbars="vertical" 
			android:layout_width="fill_parent" 
			android:layout_height="fill_parent" />
    </com.zhihu.android.app.ui.widget.FixRefreshLayout>
    <com.zhihu.android.base.widget.ZHRelativeLayout 
		android:id="@id/top_bar_container" 
		android:layout_width="fill_parent" 
		android:layout_height="wrap_content" 
		android:layout_below="@id/system_bar">
        <com.zhihu.android.app.ui.widget.live.LiveReactionCountBar 
			android:id="@id/live_cheers_count_bar" 
			android:layout_width="wrap_content" 
			android:layout_height="wrap_content" 
			android:layout_marginTop="8.0dip" 
			android:layout_below="@id/speaker_info_bar" 
			android:layout_centerHorizontal="true" />
        <com.zhihu.android.app.ui.widget.live.reactionAnimation.LiveReactionBigAnimateBackgroundView 
			android:id="@id/live_reaction_animate_background" 
			android:layout_width="fill_parent" 
			android:layout_height="104.0dip" 
			android:layout_marginLeft="64.0dip" 
			android:layout_marginTop="12.0dip" 
			android:layout_marginRight="64.0dip" 
			android:layout_below="@id/live_cheers_count_bar" />
        <include 
			android:id="@id/live_tip_layout" 
			android:clickable="true" 
			android:layout_width="fill_parent" 
			android:layout_height="wrap_content" 
			android:layout_below="@id/speaker_info_bar" 
			layout="@layout/live_tip_bar_layout" />
        <com.zhihu.android.app.ui.widget.live.LiveUpdateProgressViewList 
			android:id="@id/live_video_update_list" 
			android:layout_width="fill_parent" 
			android:layout_height="fill_parent" 
			android:layout_below="@id/speaker_info_bar" />
        <com.zhihu.android.app.ui.fragment.live.UserInfoBarView 
			android:id="@id/speaker_info_bar" 
			android:visibility="gone" 
			android:layout_width="fill_parent" 
			android:layout_height="@dimen/live_speak_info_bar_height" 
			android:layout_alignParentTop="true" />
    </com.zhihu.android.base.widget.ZHRelativeLayout>
    <com.zhihu.android.base.widget.ZHFrameLayout 
		android:id="@id/live_toast_container" 
		android:layout_width="wrap_content" 
		android:layout_height="wrap_content" 
		android:layout_marginBottom="8.0dip" 
		android:layout_above="@id/live_bottom_bar_container" 
		android:layout_centerHorizontal="true" />
    <com.zhihu.android.base.widget.ZHLinearLayout 
		android:orientation="vertical" 
		android:id="@id/live_bottom_bar_container" 
		android:layout_width="fill_parent" 
		android:layout_height="wrap_content" 
		android:layout_alignParentBottom="true" />
</com.zhihu.android.base.widget.ZHRelativeLayout>

ZHRelativeLayout ,ZHRecyclerView , ZHLinearLayout ..... 原来知乎把所有的UI控件都封装了一次,来看该封装的所有的View的package目录:


Button,CardView, CheckBox, EditText, ImageButton, ImageView....常用的封装过了的控件都在这里。

看一下ZHButton的源码:

public class ZHButton extends AppCompatButton implements IDayNightView {
//此处代码省略
}

ZHButton实现了 IDayNightView接口,该接口就是白天/夜间主题切换的接口, 来看一下该接口:

public interface abstract class IDayNightView {
    
    public abstract void resetStyle();
    
}

继续查看可以发现上述封装过的控件的确都实现了该接口。


继续查看源码发现还有个关键的ThemeManager类, 来看com.zhihu.android.base.ThemeManager的switchThemeTo()方法:

    public static void switchThemeTo(int mode) {
        setCurrentTheme(mode);
        ZHActivity activity = (ZHActivity)ZHActivity.sActivityStack.iterator().next()) {
            activity.switchTheme(mode);
        }
    }


调用了ZHActivity的switchTheme()方法:

    public void switchTheme(int mode) {
        if(overrideDefaultDayNightMode) {
            return;
        }
        int toMode = getMode(mode);
        if(isActive) {
            View decorView = getWindow().getDecorView();
            Bitmap drawingCache = obtainCachedBitmap(decorView);
            if((decorView instanceof ViewGroup) && (drawingCache != null)) {
                View maskView = new View(this);
                maskView.setBackgroundDrawable(new BitmapDrawable(getResources(), drawingCache));
                new BitmapDrawable(getResources(), drawingCache) = decorView;
                (ViewGroup)new BitmapDrawable(getResources(), drawingCache).addView(maskView, new ViewGroup.LayoutParams(-0x1, -0x1));
                onPrepareThemeChanged(toMode);
                switchThemeInternal(toMode);
                localViewPropertyAnimator1 = maskView.animate().alpha(0.0f).setDuration(0x12c)ZHActivity.1 localZHActivity.12 = new ZHActivity.1(this, decorView, maskView, toMode);
                localViewPropertyAnimator1 = maskView.animate().alpha(0.0f).setListener(localZHActivity.12);
                maskView.animate().alpha(0.0f).start();
            }
            return;
        }
        onPrepareThemeChanged(toMode);
        switchThemeInternal(toMode);
        onPostThemeChanged(toMode);
    }

关键看switchThemeInternal()方法:

    private void switchThemeInternal(int mode) {
        ResourceFlusher.flush(getResources());
        setDayNightMode(mode);
        invalidateOptionsMenu();
        supportInvalidateOptionsMenu();
        clearDrawableCache();
        reTheme();
        recolorBackground();
        ThemeManager.switchViewTree(getWindow().getDecorView());
    }
该方法里面调用关键的ThemeManager.switchViewTree(getWindow().getDecorView());方法,可是我用smali2java工具反编译出来的源码里该方法竟然反编译不出来:

    public static void switchViewTree(View view) {
        // :( Parsing error. Please contact me.
    }


知道android studio内置的反编译工具(Fernflower decompiler) 要强大一点,于是解压知乎app后得到dex文件,然后用dex2jar把dex文件转成jar文件,然后将得到的jar文件作为lib导入android studio,反编译后得到源码:

    public static void switchViewTree(View var0) {
        if(var0 instanceof IDayNightView) {
            try {
                ((IDayNightView)var0).resetStyle();
            } catch (Exception var3) {
                logException(var3);
            }
        }

        if(var0 instanceof ViewGroup) {
            for(int var1 = 0; var1 < ((ViewGroup)var0).getChildCount(); ++var1) {
                switchViewTree(((ViewGroup)var0).getChildAt(var1));
            }
        }

    }


这里也注意到,android studio内置的反编译工具虽然反编译能力强,但是反编译出来的代码可读性要差很多。来看一下两款工具反编译出的ThemeManager的差异:

smali2java反编译的:

/**
  * Generated by smali2java 1.0.0.558
  * Copyright (C) 2013 Hensence.com
  */

package com.zhihu.android.base;

import android.content.Context;
import android.support.v7.app.AppCompatDelegate;
import android.content.SharedPreferences;
import java.util.ArrayList;
import java.util.Iterator;
import android.view.View;
import com.zhihu.android.base.view.IDayNightView;
import android.content.res.Resources;
import android.content.res.Configuration;
import android.util.DisplayMetrics;

public class ThemeManager {
    private static Context sApplicationContext;
    private static ThemeManager.ThemeLogger sLogger;
    private static int sCurrentMode = 0x1;
    
    public static void init(Context context) {
        sApplicationContext = context;
        int theme = readTheme(context);
        if(theme == 0x2) {
            AppCompatDelegate.setDefaultNightMode(0x2);
            return;
        }
        AppCompatDelegate.setDefaultNightMode(0x1);
    }
    
    public static void switchViewTree(View view) {
        // :( Parsing error. Please contact me.
    }
    
    public static void switchThemeTo(int mode) {
        setCurrentTheme(mode);
        ZHActivity activity = (ZHActivity)ZHActivity.sActivityStack.iterator().next()) {
            activity.switchTheme(mode);
        }
    }
    
    public static boolean isLight() {
        return (sCurrentMode != 0x2);
    }
    
    public static boolean isDark() {
        return (sCurrentMode == 0x2);
    }
    
    public static int getCurrentTheme() {
        return sCurrentMode;
    }
    
    public static int readTheme(Context pContext) {
        SharedPreferences sharedPreferences = getSharedPreferences("theme", 0x0);
        int theme = sharedPreferences.getInt("theme", 0x1);
        if((theme != 0x1) && (theme != 0x2)) {
            theme = 0x1;
        }
        sCurrentMode = theme;
        return theme;
    }
    
    public static void setCurrentTheme(int pTheme) {
        SharedPreferences sharedPreferences = sApplicationContext.getSharedPreferences("theme", 0x0);
        sharedPreferences.edit().putInt("theme", pTheme).apply();
        sCurrentMode = pTheme;
    }
    
    public static void logException(Throwable e) {
        if(sLogger != null) {
            sLogger.log(e);
        }
    }
    
    public static void setLogger(ThemeManager.ThemeLogger logger) {
        sLogger = logger;
    }
    
    public static boolean updateConfigurationIfNeeded(Context context) {
        Resources res = getResources();
        Configuration conf = res.getConfiguration();
        int currentNightMode = conf.uiMode & 0x30;
        int newNightMode = sCurrentMode == 0x2 ? 0x20 : 0x10;
        if(currentNightMode != newNightMode) {
            Configuration config = new Configuration(conf);
            DisplayMetrics metrics = res.getDisplayMetrics();
            config.uiMode = ((config.uiMode & -0x31) | newNightMode);
            res.updateConfiguration(config, metrics);
            ZHActivity activity = findZHActivity(context);
            if(activity != null) {
                activity.switchSilently(sCurrentMode);
            }
            return true;
        }
        return false;
    }
    
    private static ZHActivity findZHActivity(Context context) {
        // :( Parsing error. Please contact me.
    }
}


Fernflower decompiler反编译出的:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package com.zhihu.android.base;

import android.content.Context;
import android.content.ContextWrapper;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.support.v7.app.AppCompatDelegate;
import android.util.DisplayMetrics;
import android.view.View;
import android.view.ViewGroup;
import com.zhihu.android.base.ZHActivity;
import com.zhihu.android.base.ThemeManager.ThemeLogger;
import com.zhihu.android.base.view.IDayNightView;
import java.util.Iterator;

public class ThemeManager {
    private static Context sApplicationContext;
    private static int sCurrentMode = 1;
    private static ThemeLogger sLogger;

    private static ZHActivity findZHActivity(Context var0) {
        while(true) {
            if(!(var0 instanceof ZHActivity)) {
                if(var0 instanceof ContextWrapper) {
                    var0 = ((ContextWrapper)var0).getBaseContext();
                    continue;
                }

                return null;
            }

            return (ZHActivity)var0;
        }
    }

    public static int getCurrentTheme() {
        return sCurrentMode;
    }

    public static void init(Context var0) {
        sApplicationContext = var0;
        if(readTheme(var0) == 2) {
            AppCompatDelegate.setDefaultNightMode(2);
        } else {
            AppCompatDelegate.setDefaultNightMode(1);
        }
    }

    public static boolean isDark() {
        return sCurrentMode == 2;
    }

    public static boolean isLight() {
        return sCurrentMode != 2;
    }

    public static void logException(Throwable var0) {
        if(sLogger != null) {
            sLogger.log(var0);
        }

    }

    public static int readTheme(Context var0) {
        int var2 = var0.getSharedPreferences("theme", 0).getInt("theme", 1);
        int var1 = var2;
        if(var2 != 1) {
            var1 = var2;
            if(var2 != 2) {
                var1 = 1;
            }
        }

        sCurrentMode = var1;
        return var1;
    }

    public static void setCurrentTheme(int var0) {
        sApplicationContext.getSharedPreferences("theme", 0).edit().putInt("theme", var0).apply();
        sCurrentMode = var0;
    }

    public static void setLogger(ThemeLogger var0) {
        sLogger = var0;
    }

    public static void switchThemeTo(int var0) {
        setCurrentTheme(var0);
        Iterator var1 = ZHActivity.sActivityStack.iterator();

        while(var1.hasNext()) {
            ((ZHActivity)var1.next()).switchTheme(var0);
        }

    }

    public static void switchViewTree(View var0) {
        if(var0 instanceof IDayNightView) {
            try {
                ((IDayNightView)var0).resetStyle();
            } catch (Exception var3) {
                logException(var3);
            }
        }

        if(var0 instanceof ViewGroup) {
            for(int var1 = 0; var1 < ((ViewGroup)var0).getChildCount(); ++var1) {
                switchViewTree(((ViewGroup)var0).getChildAt(var1));
            }
        }

    }

    public static boolean updateConfigurationIfNeeded(Context var0) {
        Resources var3 = var0.getResources();
        Configuration var4 = var3.getConfiguration();
        int var2 = var4.uiMode;
        byte var1;
        if(sCurrentMode == 2) {
            var1 = 32;
        } else {
            var1 = 16;
        }

        if((var2 & 48) != var1) {
            var4 = new Configuration(var4);
            DisplayMetrics var5 = var3.getDisplayMetrics();
            var4.uiMode = var4.uiMode & -49 | var1;
            var3.updateConfiguration(var4, var5);
            ZHActivity var6 = findZHActivity(var0);
            if(var6 != null) {
                var6.switchSilently(sCurrentMode);
            }

            return true;
        } else {
            return false;
        }
    }
}

可以 看到, smali2java反编译不出来的方法 Fernflower decompiler都反编译出来了,然而smali2java反编译出的代码可读性很强。


总结出: 有些反编译工具的反编译能力差点,但是反编译出的代码可读性强。 有些反编译工具的反编译能力好,但是反编译出的代码可读性差点,所以可以综合多种反编译工具,结合几份反编译出来的代码综合比对查看会更容易得出原代码的全貌。


好了,继续分析,上述 switchViewTree()方法采用递归遍历当前window下所有View,如果该View实现了  IDayNightView接口,就调用该接口的resetStyle()方法重新设置该View的样式,从而实现换肤,关键的代码也分析结束了。


  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值