
前言
同學(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代碼:

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、cglib、aspectJ、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($$);

輸出字符串為修改的靜態(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é)、歷史,玩玩投資,偶爾旅行。