Flutter填坑筆記:從flutter pub get error 開始,定位Dart SDK問題

TonyBuilder發表於2019-08-19

  在使用Flutter開發應用的時候,有時需要使用pub工具獲取依賴的包。但是國內的開發者往往會遇到下載失敗的問題,現象為pub程式崩潰,堆疊如下:

Running "flutter packages get" in startup_namer...
The setter 'readEventsEnabled=' was called on null.
Receiver: null
Tried calling: readEventsEnabled=false
package:pub/src/source/hosted.dart 344   BoundHostedSource._throwFriendlyError
package:pub/src/source/hosted.dart 144   BoundHostedSource.doGetVersions
複製程式碼

長文預警:TLDR版本如下,如果你只想解決下載問題,方案如下:
關閉代理,設定環境變數,使用國內映象下載。

export PUB_HOSTED_URL=https://pub.flutter-io.cn
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
複製程式碼

  GitHub上已經有多個Issue,例如/flutter/issues/25068

  Dart Team回覆的官方解決方案就是上面的辦法。

  如果你對問題根因感興趣,請往下看。

  作為一個程式設計師要有所追求,我是不滿足Dart team這樣的回覆的 ^_^。

  因為有以下疑問:

  1. 我的機器上使用了某燈,fluter的官網和dart官網訪問都沒有障礙,為何pub不行;
  2. pub需要下載的檔案,例如https://pub.dartlang.org/packages/bsdiff/versions/0.1.0.tar.gz,從瀏覽器中是可以快速下載的。pub只能從中國映象下載,而且非常慢,專案初始化時太浪費時間。
  3. 即使連線不上,pub也不能崩潰處理,從Log看肯定是程式碼邏輯有問題。

  從以上三點出發,我猜想pub沒有使用代理,還是走原來的網路連結,所以我決定跟蹤一下這個問題的根本原因。

STEP 1: 分析入口:flutter pub get 的處理流程 (flutter_tools)

   要想解決問題,首先需要需要找到入口,Android Studio工程中,更新package使用的是命令列命令:

flutter pub get
複製程式碼

   flutter 是FLUTTER SDK 中提供的指令碼,封裝了各個工具,作為SDK的總入口,真正生效的是如下語句:

"$DART" --packages="$FLUTTER_TOOLS_DIR/.packages" $FLUTTER_TOOL_ARGS "$SNAPSHOT_PATH" "$@"
複製程式碼

執行時變數如下:

dart --packages=~/flutter/packages/flutter_tools/.packages  /~/flutter/bin/cache/flutter_tools.snapshot pub get
複製程式碼

解釋一下:

  • flutter 作為一個shell指令碼,最終通過dart命令呼叫flutter_tools執行pub get命令;
  • --packages是命令執行時依賴的package路徑,這個場景沒有用到;
  • .snapshot 檔案是DART程式預編譯生成的快照檔案,可執行,可以簡單類比JAVA中的.jar檔案。
  • $@ 把後續命令原封不動轉發給fluter_tools處理。

flutter_tools程式碼路徑在FLUTTER SDK目錄下, 是一個DART語言編寫的CLI命令列工具:

~/flutter/packages/flutter_tools
複製程式碼

在IDE中可以建立DART Command Line Tool工程檢視,編譯這個工具,具體可以參考:/flutter/wiki/The-flutter-tool

簡單分析一下flutter_tool 的程式碼邏輯:

  • 專案入口:./bin/flutter_tools.dart; IDE中,配置執行時的檔案指定這個,就可以在IDE中執行起來。
void main(List<String> args) {
  executable.main(args);
}
複製程式碼
  • 命令處理流程:和JAVA, C常見的CLI程式結構類似,就是分析命令列輸入的字串,路由到對應模組進行處理,例如常用的flutter doctor 命令就在 commands/doctor.dart 中處理:
class DoctorCommand extends FlutterCommand {......}
複製程式碼
  • pub命令 :pub命令比較特殊,flutter_tools通過系統命令列介面,呼叫外部命令實現的:
main() ->
     Executable.main-> 
     FlutterCommandRunner.runCommand -> 
     PackageGetCommand._runPubGet ->
     pubGet() (lib/src/dart/pub.dart)
複製程式碼

最終通過SDK的pub元件執行的命令

/// The command used for running pub.
List<String> _pubCommand(List<String> arguments) {
  return <String>[ sdkBinaryName('pub') ]..addAll(arguments);
}
複製程式碼

也就是說,代理連線失敗,問題不在flutter_tools中,需要繼續分析pub流程。

STEP 2: 縮小範圍:pub get 的處理流程 (pub)

  • pub 的二進位制檔案路徑在**~/flutter/bin/cache/dart-sdk/bin/pub**,同樣,這是一個shell指令碼,最終執行的是。./flutter/bin/cache/dart-sdk/bin/snapshots/pub.dart.snapshot
  • 為了解決問題,我們需要pub的原始碼,pub 是dart sdk提供的工具,所以原始碼在dart-lang中,./dart-lang/pub
  • 在Android Studio中同樣配置Dart Comman Line工程,不再贅述。pug get -v 可以列印詳細log.
  • pub流程分析限於篇幅這裡省略,根據崩潰堆疊分析和程式碼邏輯,pub使用的是dart:io 中的HttpClient

STEP 3: 問題定位:DEMO復現, 編譯SDK,跟蹤SDK邏輯

  既然問題在dart:io中,我於是單獨寫了一個DEMO,使用 dart:io 中的 HttpClient 測試,發現問題竟然可以簡單復現,激動不已,繞了一大圈終於找到了責任人:

import "dart:io";
import 'dart:convert';
main() async {
  var google = "https://www.google.com/";
  var httpClient = HttpClient();
//  // Lantern proxy, cause crash
  httpClient.findProxy = (uri) {
    return "PROXY 127.0.0.1:45653";
  };
  HttpClientRequest request = await httpClient.getUrl(Uri.parse(baidu));
  HttpClientResponse response = await request.close();
  var responseBody = await response.transform(Utf8Decoder()).join();
  print(responseBody);
}
複製程式碼

解釋:

  • 測試OS:Ubuntu 18.04
  • 開啟某燈:HttpClient使用某燈作為代理(HttpClient. findProxy()設定) 執行,崩潰日誌如下, 可以看到和pub崩潰的日誌類似都有 The setter 'readEventsEnabled=' was called on null. 姑且認為是同一個問題導致的。
Unhandled exception: NoSuchMethodError: 
    The setter 'readEventsEnabled=' was called on null. 
    Receiver: null Tried calling: readEventsEnabled=false 
#0 _rootHandleUncaughtError.<anonymous closure> (dart:async/zone.dart:1112:29) 
#1 _microtaskLoop (dart:async/schedule_microtask.dart:41:21) 
#2 _startMicrotaskLoop (dart:async/schedule_microtask.dart:50:5) 
#3 _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:116:13) 
#4 _RawReceivePortImpl._handleMessage (dart:isolate-patch/isolate_patch.dart:173:5)
複製程式碼

這裡吐槽一下Dart的StackTrace,崩潰日誌完全沒有列印出現場,T_T,但是可以明確的是問題肯定發生在Dart SDK 的 dart:io library中。 於是,為了定位問題,下載SDK程式碼,編譯,跟蹤:

cd dart-sdk/sdk
./tools/build.py --mode debug --arch x64 create_sdk
複製程式碼

這裡再一次吐槽一下Dart,SDK編譯以後,除錯時竟然不能打斷點跟蹤,後續有時間需要分析一下原因。 此處省略定位流程(後續補充HttClient原始碼分析,敬請期待)。 通過跟蹤程式碼邏輯,最終定位到崩潰地址如下:

  static Future<RawSecureSocket> secure(RawSocket socket,
      {StreamSubscription<RawSocketEvent> subscription,
      host,
      SecurityContext context,
      bool onBadCertificate(X509Certificate certificate),
      List<String> supportedProtocols}) {
    **//crashed at the following line. socket == null**
    socket.readEventsEnabled = false;
    **//crashed at the following line. socket == null**
    socket.writeEventsEnabled = false;
    ......
  }
複製程式碼

HttpClient設定代理,此處socket == null;但是socket為什麼為null未知;

STEP 4 根本原因定位:

問題已經定位,但是根本原因並不清楚:socket為什麼為null,正常的代理流程應該是什麼樣。 因此,我考慮抓一個正確的場景日誌作參考: 本機建立了兩個proxyserver, 一個是tinyproxy,一個是lantern。 根據Http協議,客戶端首先向proxy server 傳送Connect 請求:

CONNECT www.google.com:443 HTTP/1.1
user-agent: Dart2.5(dart:io)
accept-encoding:gzip
content-length:0
host:www.google.com:443

tinyproxy 回覆:程式正常執行

HTTP/1.0 200 Connection established
Proxy-agent: tinyproxy/1.8.4

lantern 回覆:

HTTP/1.1 200 OK
Date: Wednesday, 14-Aug-19 16:13:22 CST
Keep-Alive: timeout=58
Content-Length: 0

崩潰!
於是,跟蹤HttpResponse解析流程,發現 http_parser.dart, _HttpParser._onData 中在處理Http響應有差異。收到lantern的響應後,由於"Content-Length: 0",_HttpParser關閉了socket,從而導致上述socket == null。而tinyproxy走不同的分支,socket得以保留,所以沒有問題。

 http_parser.dart

bool _headersEnd() {
......
if (_transferLength == 0 ||
(_messageType == _MessageType.RESPONSE && _noMessageBody)) {
_reset();
var tmp = _incoming;
 *****socket will closed here as "Content-Length: 0"
_closeIncoming();
_controller.add(tmp);
return false;
} else if (_chunked) {
_state = _State.CHUNK_SIZE;
_remainingContent = 0;
} else if (_transferLength > 0) {
_remainingContent = _transferLength;
_state = _State.BODY;
} else {
*****tinyproxy will go to this branch. not closing socket
// Neither chunked nor content length. End of body
// indicated by close.
_state = _State.BODY;
}
複製程式碼

因此,修改方案也很簡單,增加一個_keepAlive flag,當Http Response 中有 Keep-Alive 欄位時,走tinyproxy分支,不關閉socket。

void _doParse() {
...
if (headerField == "keep-alive") {
_keepAlive = true;
}
...

if ((_transferLength == 0 && !_keepAlive) // 不走這個分支,走else
...
複製程式碼

本地測試,問題解決。

最後

  洋洋灑灑一大篇,如流水帳一樣記錄了一下Dart SDK的問題定位流程。回頭來看,pub使用了代理,只不過dart:io 使用代理時出現了相容性問題。目前這個問題已經提了issues/37808,因為涉及到HttpResponse欄位的解析,需要對HTTP協議詳細分析後才能修改。所以,待最終方案入庫後SDK更新才能解決pub error的問題。不過對於我本地而言,使用本地SDK編譯的pub已經可以正常工作了。
  寫幾點學習DART的體會吧:

  • Dart的優點: 現代化的編成語言,擁有最流行的語言特性(async-await, stream, future),單執行緒模型降低編碼難度,提升gc效率。最關鍵的是基於dart的flutter框架真正的支援跨平臺,Android,iOS,Fuchsia一統天下,前景無限光明。
  • Dart的不足:太年輕,不夠成熟穩重 例如本文這個問題可以歸類為一個相容性問題。目前DART還很年輕,有很多類似的相容性的問題可能還會出現。我使用 JAVA 的 APACHE HttpClient寫了一個測試程式就沒有這樣的問題。JAVA生態成熟度要遠高於Dart
  • DART 還需要在易用性問題上作更好的修改,例如目前遇到的StackTrace不太友好,SDK中的檔案不能單步跟蹤除錯等,對開發人員都形成了一些障礙。
  • 一點建議:對於dart:io 這個lib,大量的程式碼還是以Future<>.then 的方式寫的,如果用async await方式改寫,會更好理解些。

總之,DART 有風險,如坑需謹慎,道路或曲折,前途很光明。

相關文章