Robolectric Shadow類實(shí)現(xiàn)方式探索

前言

同學(xué)們平時(shí)用robolectric可能沒(méi)太留意robolectric的Custum Shadow功能。簡(jiǎn)單地說(shuō),就是用Shadow類代替原始類,并不讓調(diào)用者感知。Shadow機(jī)制不僅僅讓用戶修改自己寫(xiě)的類,robolectric大量用到shadow機(jī)制,這是最核心的技術(shù)。

本文并不打算深入講解robolectric shadow機(jī)制,robolectric用了比較復(fù)雜的原理。筆者希望用更簡(jiǎn)單的方式,實(shí)現(xiàn)基本的shadow機(jī)制。

Shadow是什么?

官方原文:

Robolectric defines many shadow classes, which modify or extend the behavior of classes in the Android OS......Every time a method is invoked on an Android class, Robolectric ensures that the shadow class’ corresponding method is invoked first.

大概意思是,robolectric有很多shadow類來(lái)修改或拓展Android OS原本的類......每一次執(zhí)行android類時(shí),robolectric確保shadow類先執(zhí)行。

簡(jiǎn)單的例子:

Foo:

public class Foo {

    public void display(){
        System.out.println("foo");
    }
}

ShadowFoo:

@Implements(Foo.class)
public class ShadowFoo {

    @Implementation
    public void display(){
        System.out.println("shadow foo");
    }
}

運(yùn)行單元測(cè)試時(shí),執(zhí)行單元測(cè)試:

@RunWith(RobolectricTestRunner.class)
@Config(shadows = {ShadowFoo.class}, manifest = Config.NONE)
public class FooTest {

    Foo foo;

    @Before
    public void setUp() throws Exception {
        foo = new Foo();
    }

    @Test
    public void display() throws Exception {
        foo.display();
    }
}

運(yùn)行結(jié)果:

shadow foo

Robolectric單元測(cè)試,配置Shadow后,ShadowFoo會(huì)覆蓋Foo行為。你可以寫(xiě)很多ShadowFoo,單元測(cè)試時(shí)配置不同的Shadow做不同的行為。

Shadow意義何在?

覆蓋Android sdk行為

在Android Studio可以看到Android大部分源;我們運(yùn)行APP后,在Android Studio打斷點(diǎn)debug代碼,可以看到android代碼執(zhí)行。實(shí)際上,APP執(zhí)行的是手機(jī)Android系統(tǒng)的代碼,并不是我們AS依賴的sdk。那么,單元測(cè)試依賴的android sdk,真的跟我們?cè)贏S看到的代碼一樣嗎?

我們做個(gè)簡(jiǎn)單的測(cè)試:

public class TextUtilsTest {

    @Test
    public void testIsEmpty() {
        TextUtils.isEmpty("");
    }
}

結(jié)果是這樣:

java.lang.RuntimeException: Method isEmpty in android.text.TextUtils not mocked. See http://g.co/androidstudio/not-mocked for details.
at android.text.TextUtils.isEmpty(TextUtils.java)
at com.example.robolectric.TextUtilsTest.testIsEmpty(TextUtilsTest.java:14)
...

我們?cè)贏S查看TextUtils.isEmpty源碼:

    public static boolean isEmpty(@Nullable CharSequence str) {
        if (str == null || str.length() == 0)
            return true;
        else
            return false;
    }

這里都是jdk提供的基礎(chǔ)代碼,為什么就報(bào)錯(cuò)了呢?

我們?cè)贏S查看依賴的android sdk路徑:

1.右鍵->Show in Explore

sdk路徑:{sdk目錄}/platforms/android-25 (sdk不同版本在不同目錄)

2.然后用Java Decompiler查看這個(gè)jar代碼:

TextUtils.isEmpty()

android.jar的代碼,只是一個(gè)stub,里面根本沒(méi)有android源碼,全部方法都throw new RuntimeException("Stub!")。

因此,robolectric在運(yùn)行時(shí),需要替換這些代碼。這就是Shadow機(jī)制存在的必要!

(提醒,robolectric替換android代碼,并不是所有都用shadow機(jī)制,大部分只是讓ClassLoader加載robolectric提供的android-all.jar而已。View類基本用Shadow機(jī)制。)

控制依賴外部環(huán)境的方法行為

大多數(shù)情況下,我們用mock就能做到控制方法行為。但一些靜態(tài)方法,例如NetworkUtils.isConnected(),mockito就做不到了。當(dāng)然可以用powermockito,筆者認(rèn)為mockito和powermockito混合使用比較蛋疼,畢竟方法名很多雷同,引用時(shí)比較麻煩。

場(chǎng)景:1.網(wǎng)絡(luò)正常,返回mock數(shù)據(jù);2.網(wǎng)絡(luò)斷開(kāi),拋出異常。

public class UserApi {

    Observable<String> getMyInfo() {
        if (NetworkUtils.isConnected()) {
            return Observable.just("...");
        } else {
            return Observable.error(new RuntimeException("Network disconnected."));
        }
    }
}

Shadow:

@Implements(NetworkUtils.class)
public class ShadowNetworkUtils {

    public static boolean sIsConnected;

    @Implementation
    public static boolean isConnected() {
        return sIsConnected;
    }

    public static void setIsConnected(boolean isConnected) {
        ShadowNetworkUtils.sIsConnected = isConnected;
    }
}

單元測(cè)試:

@RunWith(RobolectricTestRunner.class)
@Config(shadows = ShadowNetworkUtils.class)
public class UserApiTest {

    UserApi userApi;

    @Before
    public void setUp() throws Exception {
        userApi = new UserApi();
    }

    @Test
    public void testGetMyInfo() {

        ShadowNetworkUtils.setIsConnected(true);

        String data = userApi.getMyInfo()
                             .toBlocking()
                             .first();

        Assert.assertEquals(data, "...");
    }

    // 期望拋出錯(cuò)誤
    @Test(expected = RuntimeException.class)
    public void testNetworkDisconnected() {
        ShadowNetworkUtils.setIsConnected(false);

        userApi.getMyInfo()
               .subscribe();
    }
}

由于NetworkUtils.setIsConnected()根據(jù)真實(shí)網(wǎng)絡(luò)情況返回true or false,而且使用android api,所以運(yùn)行單元測(cè)試必然報(bào)錯(cuò)。因此,我們希望能模擬網(wǎng)絡(luò)正常和網(wǎng)絡(luò)斷開(kāi)的情況,用ShadowNetworkUtils非常適合。


自己實(shí)現(xiàn)Shadow

思路

原始類方法調(diào)用Shadow類方法

這種方法需要在jvm動(dòng)態(tài)改變?cè)碱愖止?jié)碼,本方法存在Shadow類對(duì)象或者調(diào)用實(shí)際Shadow類靜態(tài)方法,而不僅僅把Shadow類字節(jié)碼拷貝給原始類。這么說(shuō)有點(diǎn)抽象,繼續(xù)看下文就懂了。

框架選型

動(dòng)態(tài)修改jvm字節(jié)碼,有好幾款框架:asm、cglibaspectJ、javassist等。

asm比較底層,非常難用;mockito就是用到cglib,筆者感覺(jué)cglib做動(dòng)態(tài)代理比較在行,未試過(guò)修改字節(jié)碼,有待考究;aspectJ筆者最喜歡,語(yǔ)法簡(jiǎn)潔,但最大問(wèn)題是,筆者還不會(huì)在Android Studio配置成讓單元測(cè)試可用(如果你懂的請(qǐng)留言);javassist api跟java反射api很像,也挺簡(jiǎn)單的,很快上手。

最后筆者選擇了javassist。

實(shí)戰(zhàn)

gradle

在build.gradle依賴javassist:

dependencies {
    testCompile group: 'org.javassist', name: 'javassist', version: '3.21.0-GA'
}

準(zhǔn)備工具類

Robolectric的Implements注解(你也可以自己寫(xiě))

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
public @interface Implements {

  /**
   * @return The class to shadow.
   */
  Class<?> value() default void.class;

  /**
   * @return class name.
   */
  String className() default "";
}

注解工具類:

import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;

import javassist.ClassPool;
import javassist.CtClass;
import javassist.NotFoundException;
import javassist.bytecode.annotation.Annotation;
import javassist.bytecode.annotation.AnnotationImpl;
import javassist.bytecode.annotation.ClassMemberValue;
import javassist.bytecode.annotation.MemberValue;
import javassist.bytecode.annotation.StringMemberValue;

public class AnnotationHelper {

        /**
     * 獲取Shadow類{@linkplain Implements}注解的類名
     *
     * @param clazz
     * @return
     * @throws ClassNotFoundException
     * @throws NotFoundException
     */
    public static String getAnnotationClassName(Class clazz) throws ClassNotFoundException, NotFoundException {

        ClassPool pool = ClassPool.getDefault();
        CtClass   cc   = pool.get(clazz.getName());

        Implements implememts = (Implements) cc.getAnnotation(Implements.class);
        String     className  = implememts.className();

        if (className == null || className.equals("")) {
            // 獲取Implements注解value值
            className = getValue(implememts, "value");
        }

        return className;
    }

    /**
     * 獲取注解某參數(shù)值
     */
    private static String getValue(Object obj, String param) {
        AnnotationImpl annotationImpl = (AnnotationImpl) getAnnotationImpl(obj);
        Annotation     annotation     = annotationImpl.getAnnotation();
        MemberValue    memberValue    = annotation.getMemberValue(param);

        if (memberValue instanceof ClassMemberValue) {
            return ((ClassMemberValue) memberValue).getValue();
        } else if (memberValue instanceof StringMemberValue) {
            return ((StringMemberValue) memberValue).getValue();
        }
        return "";
    }

    private static InvocationHandler getAnnotationImpl(Object obj) {
        Class clz = obj.getClass()
                       .getSuperclass();

        try {
            Field field = clz.getDeclaredField("h");
            field.setAccessible(true);

            InvocationHandler annotation = (InvocationHandler) field.get(obj);

            return annotation;
        } catch (Exception e) {
            e.printStackTrace();
        }

        return null;
    }
}

動(dòng)態(tài)改變字節(jié)碼

我們希望NetworkUtils修改后,有如下效果:

public class NetworkUtils {

    public static boolean isConnected() {
        return ShadowNetworkUtils.isConnected();
    }
}

因此,我們要?jiǎng)討B(tài)生成跟上面一模一樣的源碼的字節(jié)碼,通過(guò)javassist替換原始類的方法。

public class JavassistHelper {

    public static void callShadowStaticMethod(Class<?> shadowClass) {
        try {
            // 原始類類名
            String primaryClassName = AnnotationHelper.getAnnotationClassName(shadowClass);

            ClassPool cp = ClassPool.getDefault();

            // 原始類CtClass
            CtClass cc = cp.get(primaryClassName);
            // Shadow類CtClass
            CtClass shadowCt = cp.get(shadowClass.getName());

            CtMethod[] methods = cc.getDeclaredMethods();

            for (CtMethod method : methods) {
                // 僅處理靜態(tài)方法
                if (Modifier.isStatic(method.getModifiers())) {
                    // 從Shadow類CtClass獲取方法名、參數(shù)與原始類一致的CtMethod
                    CtMethod shadowMethod = shadowCt.getDeclaredMethod(method.getName(), method.getParameterTypes());

                    if (shadowMethod != null) {
                        String src = getStaticMethodSrc(shadowClass, shadowMethod);

                        method.setBody(src);

                        // 輸出該方法源碼
                        System.out.println(src);
                    }
                }
            }

            // 最后讓jvm加載一下修改后的類
            Class c = cc.toClass();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private static String getStaticMethodSrc(Class<?> shadowClass, CtMethod method) {

        StringBuilder sb = new StringBuilder();
        try {
            CtClass returnType = method.getReturnType();

            if (!isVoid(returnType)) {
                sb.append("return ");
            }

            sb.append(shadowClass.getName() + "." + method.getName() + "($$);");// $$表示該方法所有參數(shù)
        } catch (NotFoundException e) {
            e.printStackTrace();
        }

        return sb.toString();
    }

    private static boolean isVoid(CtClass returnType) {

        if (returnType.equals(CtClass.voidType)) {
            return true;
        }

        return false;
    }
}

單元測(cè)試

public class NetworkUtilsTest {

    @Before
    public void setUp() throws Exception {
        // 修改NetworkUtils靜態(tài)方法字節(jié)碼,此方法必須在jvm加載NetworkUtils之前調(diào)用
        JavassistHelper.callShadowStaticMethod(ShadowNetworkUtils.class);
    }

    @Test
    public void testIsConnected() {
        ShadowNetworkUtils.setIsConnected(false);

        Assert.assertFalse(NetworkUtils.isConnected());

        ShadowNetworkUtils.setIsConnected(true);

        Assert.assertTrue(NetworkUtils.isConnected());
    }
}

單元測(cè)試通過(guò),并輸出:

return com.example.robolectric.ShadowNetworkUtils.isConnected($$);

unit test pass

輸出字符串為修改的靜態(tài)方法源碼。如果是非靜態(tài)方法,建議用mockito處理。


寫(xiě)在最后

筆者寫(xiě)本文的初衷,一來(lái)是想擺脫powermockito和robolectric,二來(lái)借此研究robolectric shadow實(shí)現(xiàn)原理。不料,robolectric不是浪得虛名,shadow機(jī)制非常復(fù)雜,一時(shí)半刻筆者只了解冰山一角,希望有朝一日能弄明白跟大家分享。

希望本文給大家跟多啟發(fā),用javassist在單元測(cè)試實(shí)現(xiàn)更多功能。


關(guān)于作者

我是鍵盤(pán)男。

在廣州生活,在互聯(lián)網(wǎng)體育公司上班,猥瑣文藝碼農(nóng)。每天謀劃砍死產(chǎn)品經(jīng)理。喜歡科學(xué)、歷史,玩玩投資,偶爾旅行。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • Android 自定義View的各種姿勢(shì)1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 178,725評(píng)論 25 709
  • 一.基本介紹 背景: 目前處于高速迭代開(kāi)發(fā)中的Android項(xiàng)目往往需要除黑盒測(cè)試外更加可靠的質(zhì)量保障,這正是單元...
    anmi7閱讀 2,159評(píng)論 0 6
  • 1、真正的學(xué)習(xí)體驗(yàn)是由苦到樂(lè)! “古之學(xué)者為己,今之學(xué)者為人?!睂W(xué)習(xí)得根本目的是為自己領(lǐng)悟真理指導(dǎo)實(shí)踐,而不是與別...
    rebirth_2017閱讀 383評(píng)論 0 2
  • 截止到剛才(晚上10點(diǎn)多),我把自己的第一套正裝的全部行頭已經(jīng)購(gòu)買完畢。 上午花了一個(gè)多小時(shí)了解了一下...
    耐心長(zhǎng)閱讀 125評(píng)論 0 0
  • 為冰凍預(yù)備的十月 在歡愉中縮小了尺寸 我從地下街道的方向 看到戀人眼中的戀人 葉落已不是惆悵的聲音 鋪滿腳底的最后...
    北郊PM2丶5閱讀 174評(píng)論 0 4

友情鏈接更多精彩內(nèi)容