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字节码,有好几款框架:asm、cglib、aspectJ、javassist等。
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());
}
}
单元测试通过,并输出:
unit test passreturn com.example.robolectric.ShadowNetworkUtils.isConnected($$);
输出字符串为修改的静态方法源码。如果是非静态方法,建议用mockito处理。
写在最后
笔者写本文的初衷,一来是想摆脱powermockito和robolectric,二来借此研究robolectric shadow实现原理。不料,robolectric不是浪得虚名,shadow机制非常复杂,一时半刻笔者只了解冰山一角,希望有朝一日能弄明白跟大家分享。
希望本文给大家跟多启发,用javassist在单元测试实现更多功能。
关于作者
我是键盘男。
在广州生活,在互联网体育公司上班,猥琐文艺码农。每天谋划砍死产品经理。喜欢科学、历史,玩玩投资,偶尔旅行。