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在单元测试实现更多功能。


关于作者

我是键盘男。

在广州生活,在互联网体育公司上班,猥琐文艺码农。每天谋划砍死产品经理。喜欢科学、历史,玩玩投资,偶尔旅行。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,911评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,014评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 142,129评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,283评论 1 264
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,159评论 4 357
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,161评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,565评论 3 382
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,251评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,531评论 1 292
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,619评论 2 310
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,383评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,255评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,624评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,916评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,199评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,553评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,756评论 2 335

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,081评论 25 707
  • 一.基本介绍 背景: 目前处于高速迭代开发中的Android项目往往需要除黑盒测试外更加可靠的质量保障,这正是单元...
    anmi7阅读 2,000评论 0 6
  • 1、真正的学习体验是由苦到乐! “古之学者为己,今之学者为人。”学习得根本目的是为自己领悟真理指导实践,而不是与别...
    rebirth_2017阅读 306评论 0 2
  • 截止到刚才(晚上10点多),我把自己的第一套正装的全部行头已经购买完毕。 上午花了一个多小时了解了一下...
    耐心长阅读 84评论 0 0
  • 为冰冻预备的十月 在欢愉中缩小了尺寸 我从地下街道的方向 看到恋人眼中的恋人 叶落已不是惆怅的声音 铺满脚底的最后...
    北郊PM2丶5阅读 121评论 0 4