Robolectric Shadow 类实现方式探索

Android · kkmike999 · 于 发布 · 270 次阅读
96

前言

同学们平时用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在单元测试实现更多功能。


关于作者

我是键盘男。

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

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册