關於android-async-http的單元測試

前言

最近由於業務需要,就轉行做Android了。

作為一個TDD的實踐者,以及被Apple慣出一身臭毛病的人,實在是不能接受Android原生的apache.webclient。就像我們很少用NSURLConnection一樣。這個時候,我讓同事調研了一些Android網絡的封裝庫,他選擇了https://github.com/loopj/android-async-http 這個庫。

這個庫我很有感情的,兩年前做Android開發課的大作業時,就用了它。那個時候我還在用ASIHTTPRequest,接觸到這個庫的時候被亮瞎了雙眼。就根據這個庫的API,在NSURLConnection層面封裝了一個出來。

之後,用了AFNetworking,發現API也和這個神似,好了不扯淡了,開始正題。

正常的網絡單元測試

首先,網上很多大神都不推薦給網絡寫單元測試,更適合用mock的方法。

但若你和我一樣偏執的話,那麼就給出來做法。

就拿iOS來說:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- (void) testHTTPRequest
{
    __block BOOL flag = YES;
    ETNetworkAdapter* adapter = [ETNetworkAdapter sharedAdapter];
    [adapter HTTPRequestWithMethod:@"POST"
                           baseURL:@"http://api.insysu.com/v2/"
                              path:@"signin"
                            params:@{@"username":@"10389235",
                                     @"password":@"10389235"}
                           success:^(AFHTTPRequestOperation *operation, id responseObject) {
                               flag = NO;
                               XCTAssertEqualObjects([responseObject valueForKey:@"message"], @"OK", @"error");
                           } failure:^(AFHTTPRequestOperation *operation, NSError *error) {
                               flag = NO;
                               NSLog(@"%@", operation.response);
                               XCTFail(@"%@", error);
                           }];

    while (flag) {
        [[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode
                              beforeDate:[NSDate distantFuture]];
    }
}

這段代碼取自我們學校第三方教務系統項目。

網絡請求常理上說,都是異步的,那麼我們測試線程跑的時候,就會出問題。

如同女神對屌絲說,你去給我買瓶水吧,屌絲就傻逼傻逼的去買了,然後女神走了。女神是不知道你買的是什麼水,合不合口味,買沒買到之類的,更不會犒勞你。

所以需要給女神上把鎖,讓她等著你,即要把一個異步線程編程同步線程。

異步變同步──NSRunLoop

對於iOS常見的異步變同步方式

有兩種,一種是我前面代碼中寫的,用RunLoop的方法。看起來像是一個死循環一樣低效的東西。但他並不是忙等待

眾所周知,在Dijkstra提出Semaphore的理念之前,操作系統都是使用自旋等待這種低效的方式來處理線程鎖的。但是,Runloop並不是自璇。下面那句

1
2
3
[[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode
                              beforeDate:[NSDate distantFuture]];

是繼續讓iOS主循環工作的意思。這種做法,很漂亮,但是侷限性太大了。如果其他系統沒有類似功能的話,就沒法使用。

第二種方式,我們要著重講解下,因為這是通解,之後Android的代碼也是用的它,不過不漂亮就是了。

異步變同步──Semaphore

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
dispatch_semaphore_t signal = dispatch_semaphore_create(0);
ETNetworkAdapter* adapter = [ETNetworkAdapter sharedAdapter];
[adapter HTTPRequestWithMethod:@"POST"
                       baseURL:@"http://api.insysu.com/v2/"
                          path:@"signin"
                        params:@{@"username":@"10389235",
                                 @"password":@"10389235"}
                       success:^(AFHTTPRequestOperation *operation, id responseObject) {
                           XCTAssertEqualObjects([responseObject valueForKey:@"message"], @"OK", @"error");
                           dispatch_semaphore_signal(signal);
                       } failure:^(AFHTTPRequestOperation *operation, NSError *error) {
                           NSLog(@"%@", operation.response);
                           XCTFail(@"%@", error);
                           dispatch_semaphore_signal(signal);
                       }];
dispatch_semaphore_wait(signal, DISPATCH_TIME_FOREVER);

流程如下:

  • 創建一個新的線程,讓主線程通過semaphore.wait休眠掉。

  • 子線程工作完成,發出semaphore.signal,讓信號量﹣1,結束子線程

  • 當semaphore發現自己信號小於0,結束等待,繼續執行。

注意:這個有個一很需要注意的問題。如果call back的時候,是調用主線程的話,就出會出現死鎖。

Android的單元測試

作為一個初上手的人,會下意識地根據iOS的規則寫一個網絡測試出來。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public void setUp() throws Exception {
  super.setUp();
  adapter = NetworkAdapter.sharedAdapter();
  lock = new CountDownLatch(1);
}

public void testRequestWithMethodAndAbsoluteURL() throws Exception {
  responseStr = null;
  adapter.requestWithMethodAndAbsoluteURL("GET", "http://www.tietie.la", null, new AsyncHttpResponseHandler()
  {
      @Override
      public void onStart()
      {
          Log.i("unit test", "Fuck");
      }

      @Override
      public void onSuccess(String responseObj) {
          responseStr = responseObj;
      }

      @Override
      public void onFailure(int statusCode, org.apache.http.Header[] headers, byte[] responseBody, java.lang.Throwable error)
      {
          assertFalse(true);
      }

      @Override
      public void onFinish()
      {
          lock.countDown();
      }
  });
  lock.await(2000, TimeUnit.MILLISECONDS);
  assertNotNull(responseStr);
}

結果是出乎預料的空指針。那麼哪裡出現了錯誤呢?

首先我們要知道他的底層實現是如何做的,翻了翻源碼發現AsyncHttpRequest實現Runnable接口,也就是說Request自己是在一個線程運行。

然後我們看到在AsyncHttpRequest中有以下代碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private void makeRequest() throws IOException {
    if (isCancelled()) {
        return;
    }
    // Fixes #115
    if (request.getURI().getScheme() == null) {
        // subclass of IOException so processed in the caller
        throw new MalformedURLException("No valid URI scheme was provided");
    }

    HttpResponse response = client.execute(request, context);

    if (!isCancelled() && responseHandler != null) {
        responseHandler.sendResponseMessage(response);
    }
}

這裡要注意兩點:

  • client發起了一個同步請求。
  • 當前網絡線程通過responseHandler也就是Handler的sendMessage來做線程間通訊的。

Android 事件分發

Android的事件分發非常有趣,Handler 和 Looper 的搭配,非常像NSOperation 和 NSOperationQueue。

不過還不太一樣,首先,Looper維護了一個消息隊列,即它裡面封裝了一個MessageQueue,作為開發者,我們無須去考慮如何控制內部。只要知道,Looper是處理他所綁定的Handler的事件分發就好了。

在Android裡面,我們通常都是使用主線程的Looper,就像iOS都是用MainRunLoop一樣。但同時,並不是其他線程不能擁有屬於自己的Looper。我們可以這樣:

1
2
3
4
5
6
7
8
9
10
11
12
public class LooperThread extends Thread {
    @Override
    public void run() {
        // 将当前线程初始化为Looper线程
        Looper.prepare();

        // ...其他处理,如实例化handler

        // 开始循环处理消息队列
        Looper.loop();
    }
}

這裡來詳細說下Handler,這個才是整個問題的核心。他的API 對於開發者來說,耦合度非常非常低。一般情況下,開發者是不需要考慮線程問題的。

但這裡要注意一點

Google文檔裡面說:

Each Handler instance is associated with a single thread and that thread’s message queue. When you create a new Handler, it is bound to the thread / message queue of the thread that is creating it — from that point on, it will deliver messages and runnables to that message queue and execute them as they come out of the message queue.

也就是說,Handler創建的時候,不是默認綁定到主線程的Looper,而是默認綁定的創建它的線程的Looper。除非你將主線程的Looper當做參數傳進去。

通常情況下,Handler的工作流程是這樣的:

  • 創建一條 message
  • 調用Handler的SendMessage將上一步的Message發送出去。
  • Handler自動將收到的Message丟給消息隊列。
  • 消息隊列默認基於FIFO,依次處理Message。
  • Message Queue 會調用 Handler 的handlerMessage API 來處理每個Message

可以藉助 Pro Android 的插圖理解這個流程

單元測試存在的問題

那麼對於我們之前所說的Android的單元測試問題,出在哪裡呢?

首先,我們可以通過斷點來看一個問題。

這時候我們會發現…Android的單元測試的testCase不在主線程跑。

那麼這樣,這個線程應該就是沒有Looper的。所以我們的請求發出去了,但是Handler的 HandleMessage 挂了。

我們通過給jar掛src,下斷點的方法,證實了這個問題,即一直到sendMessage都是正常的。

正確做法

通過查閱文檔,我們發現 ActivityTestCase 擁有一個可以在主線程運行的測試API:runTestOnUiThread

於是乎,將測試代碼都丟進去就好了。

不過要注意個問題,lock要放在測試線程,否則會出現這麼一個問題:

主線程去跑了,子線程提前結束了,那麼也是沒有進行assert的。

結果就如下代碼了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public void testRequestWithMethodAndAbsoluteURL() throws Exception {
  responseStr = null;

  try {
      this.runTestOnUiThread(new Runnable() {
          @Override
          public void run() {
              adapter.requestWithMethodAndAbsoluteURL("GET", "http://www.tietie.la", null, new AsyncHttpResponseHandler()
              {
                  @Override
                  public void onStart()
                  {
                      Log.i("unit test", "Fuck");
                  }

                  @Override
                  public void onSuccess(String responseObj) {
                      responseStr = responseObj;
                  }

                  @Override
                  public void onFailure(int statusCode, org.apache.http.Header[] headers, byte[] responseBody, java.lang.Throwable error)
                  {
                      assertFalse(true);
                  }

                  @Override
                  public void onFinish()
                  {
                      lock.countDown();
                  }
              });


          }
      });
  }
  catch (Throwable e)
  {

  }

  try {
      lock.await(2000, TimeUnit.MILLISECONDS);
  }
  catch (InterruptedException e)
  {

  }

  assertNotNull(responseStr);
}

Comments