Robolectric Shadow類實現方式探索

前言

同學們平時用robolectric可能沒太留意robolectric的Custum Shadow功能。簡單地說,就是用Shadow類代替原始類,并不讓調用者感知。Shadow機制不僅僅讓用戶修改自己寫的類,robolectric大量用到shadow機制,這是最核心的技術。

本文并不打算深入講解robolectric shadow機制,robolectric用了比較復雜的原理。筆者希望用更簡單的方式,實現基本的shadow機制。

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類來修改或拓展Android OS原本的類......每一次執行android類時,robolectric確保shadow類先執行。

簡單的例子:

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");
    }
}

運行單元測試時,執行單元測試:

@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();
    }
}

運行結果:

shadow foo

Robolectric單元測試,配置Shadow后,ShadowFoo會覆蓋Foo行為。你可以寫很多ShadowFoo,單元測試時配置不同的Shadow做不同的行為。

Shadow意義何在?

覆蓋Android sdk行為

在Android Studio可以看到Android大部分源;我們運行APP后,在Android Studio打斷點debug代碼,可以看到android代碼執行。實際上,APP執行的是手機Android系統的代碼,并不是我們AS依賴的sdk。那么,單元測試依賴的android sdk,真的跟我們在AS看到的代碼一樣嗎?

我們做個簡單的測試:

public class TextUtilsTest {

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

結果是這樣:

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)
...

我們在AS查看TextUtils.isEmpty源碼:

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

這里都是jdk提供的基礎代碼,為什么就報錯了呢?

我們在AS查看依賴的android sdk路徑:

1.右鍵->Show in Explore

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

2.然后用Java Decompiler查看這個jar代碼:

TextUtils.isEmpty()

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

因此,robolectric在運行時,需要替換這些代碼。這就是Shadow機制存在的必要!

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

控制依賴外部環境的方法行為

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

場景:1.網絡正常,返回mock數據;2.網絡斷開,拋出異常。

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;
    }
}

單元測試:

@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, "...");
    }

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

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

由于NetworkUtils.setIsConnected()根據真實網絡情況返回true or false,而且使用android api,所以運行單元測試必然報錯。因此,我們希望能模擬網絡正常和網絡斷開的情況,用ShadowNetworkUtils非常適合。


自己實現Shadow

思路

原始類方法調用Shadow類方法

這種方法需要在jvm動態改變原始類字節碼,本方法存在Shadow類對象或者調用實際Shadow類靜態方法,而不僅僅把Shadow類字節碼拷貝給原始類。這么說有點抽象,繼續看下文就懂了。

框架選型

動態修改jvm字節碼,有好幾款框架:asmcglibaspectJjavassist等。

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

最后筆者選擇了javassist。

實戰

gradle

在build.gradle依賴javassist:

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

準備工具類

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

@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;
    }

    /**
     * 獲取注解某參數值
     */
    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;
    }
}

動態改變字節碼

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

public class NetworkUtils {

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

因此,我們要動態生成跟上面一模一樣的源碼的字節碼,通過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) {
                // 僅處理靜態方法
                if (Modifier.isStatic(method.getModifiers())) {
                    // 從Shadow類CtClass獲取方法名、參數與原始類一致的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() + "($$);");// $$表示該方法所有參數
        } catch (NotFoundException e) {
            e.printStackTrace();
        }

        return sb.toString();
    }

    private static boolean isVoid(CtClass returnType) {

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

        return false;
    }
}

單元測試

public class NetworkUtilsTest {

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

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

        Assert.assertFalse(NetworkUtils.isConnected());

        ShadowNetworkUtils.setIsConnected(true);

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

單元測試通過,并輸出:

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

unit test pass

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


寫在最后

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

希望本文給大家跟多啟發,用javassist在單元測試實現更多功能。


關于作者

我是鍵盤男。

在廣州生活,在互聯網體育公司上班,猥瑣文藝碼農。每天謀劃砍死產品經理。喜歡科學、歷史,玩玩投資,偶爾旅行。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 173,552評論 25 708
  • 一.基本介紹 背景: 目前處于高速迭代開發中的Android項目往往需要除黑盒測試外更加可靠的質量保障,這正是單元...
    anmi7閱讀 2,061評論 0 6
  • 1、真正的學習體驗是由苦到樂! “古之學者為己,今之學者為人。”學習得根本目的是為自己領悟真理指導實踐,而不是與別...
    rebirth_2017閱讀 335評論 0 2
  • 截止到剛才(晚上10點多),我把自己的第一套正裝的全部行頭已經購買完畢。 上午花了一個多小時了解了一下...
    耐心長閱讀 101評論 0 0
  • 為冰凍預備的十月 在歡愉中縮小了尺寸 我從地下街道的方向 看到戀人眼中的戀人 葉落已不是惆悵的聲音 鋪滿腳底的最后...
    北郊PM2丶5閱讀 136評論 0 4