Flutter終端模擬器實現-原理解析與集成

前言

  • 我仍是那個成天用祖傳代碼的夢魘獸🤫 。linux

  • 我夢某人又來了,說了去複習期末考試的期間,這已是第三篇文章了,最近因爲項目對該部分的需求擴大,因此我抽了一整下午的時間來優化這部分的代碼。android

  • 一切的原由都源於個人我的項目中須要用到完整的終端模擬器。ios

而我的項目的UI是純Flutter的項目,不涉及任何原生的頁面,若是須要集成一個終端模擬器,那麼:git

  • 1.我能夠用PlatformView對接Termux開源的View。
  • 2.用Flutter重構一個跨平臺的終端模擬器

我我的項目使用Flutter的初心並非跨ios,而是跨平臺到pc,因此這還有得選嗎🤣 。github

上一篇文章寫得匆忙,上篇僅僅是對終端模擬器底層實現原理的解析。算法

這篇咱們講如何將它對接到Flutter,而且在極少代碼的改動下,同時跨mac/linux/android平臺。shell

上篇文章-->開源一個Flutter編寫的完整終端模擬器macos

上篇的的開源地址是集成它的項目地址編程

本篇主要涉及

  • 1.Dart建立終端
  • 2.Dart對終端輸入輸出的實現
  • 3.終端序列的重寫
  • 4.Flutter終端的顯示
  • 5.多終端的管理與建立

開源地址在最後windows

1.Dart建立終端

由上篇文章能夠得知,C Native給咱們提供的函數有兩個(詳見上一篇文章)

  • 建立終端對
int create_ptm(int rows,int columns) 複製代碼
  • 在已得到的終端對執行子程序
int create_subprocess(char *env,char const *cmd,char const *cwd,char *const argv[],char **envp,int *pProcessId,int ptmfd) 複製代碼

其實應該還有幾個,目前因爲Flutter端的字體是as design,因此設置屏幕寬度控制它換行的時機沒法實現,若是有請私信我哦

dart:ffi的一套無非就是,將native的方法或者函數與dart的方法或函數一一對應起來,隨後將其相互綁定便可。

這部分須要ffi的包

1.1 建立終端對

原生函數在Dart的對應聲明

typedef create_ptm = Int32 Function(Int32 row, Int32 column);
複製代碼

名字不要大寫,由於它是一個native function

對應Dart可調用的函數

typedef CreatePtm = int Function(int row, int column);
複製代碼

建立指向原生函數的指針

final Pointer<NativeFunction<create_ptm>> getPtmIntPointer =
    dylib.lookup<NativeFunction<create_ptm>>('create_ptm');
複製代碼

dart用泛型來表示指針指向的類型

Pointer<Int32> 對應 int *

使用上面的指針來初始化可被dart調用的函數

即綁定過程

final CreatePtm createPtm = getPtmIntPointer.asFunction<CreatePtm>();
複製代碼

調用建立

final int currentPtm = createPtm(300, 300);
複製代碼

這行代碼被執行的時候,在對應的設備的/dev/pts/目錄就立馬會多出一個文件,因此這也是檢測是函數否調用成功。 300,300是終端模擬器的寬高,隨意寫的一個值,它的數值會影響終端換行符的位置,這部分尚未作研究。仍是因爲目前我沒法控制字體換行的時機。

因此到這終端對就建立好了

1.2 在已得到的終端對執行子程序

能夠看到這個函數須要的參數比較多,因此對應的dart的代碼也比較複雜

但這部分的總體套路與上面同樣

對應聲明

typedef create_subprocess = Void Function(
    Pointer<Utf8> env,
    Pointer<Utf8> cmd,
    Pointer<Utf8> cwd,
    Pointer<Pointer<Utf8>> argv,
    Pointer<Pointer<Utf8>> envp,
    Pointer<Int32> pProcessId,
    Int32 ptmfd);
typedef CreateSubprocess = void Function(
    Pointer<Utf8> env,
    Pointer<Utf8> cmd,
    Pointer<Utf8> cwd,
    Pointer<Pointer<Utf8>> argv,
    Pointer<Pointer<Utf8>> envp,
    Pointer<Int32> pProcessId,
    int ptmfd);
複製代碼

完整代碼(帶詳細註釋)

// 找到在當前終端對建立子程序的原生指針,指向C語言中create_subprocess這個函數
    final Pointer<NativeFunction<create_subprocess>> createSubprocessPointer =
        dylib.lookup<NativeFunction<create_subprocess>>('create_subprocess');

    /// 將上面的指針轉換爲dart可執行的方法
    final CreateSubprocess createSubprocess =
        createSubprocessPointer.asFunction<CreateSubprocess>();
    // 建立一個對應原生char的二級指針並申請一個字節長度的空間
    final Pointer<Pointer<Utf8>> argv = allocate(count: 1);

    /// 將雙重指針的第一個一級指針賦值爲空
    /// 等價於
    /// char **p = (char **)malloc(1);
    /// p[1] = 0; p[1] = NULL; *p = 0; *p = NULL;
    /// 上一行的4個語句都是等價的
    /// 將第一個指針賦值爲空的緣由是C語言端遍歷這個argv的方法是經過判斷當前指針是否爲空做爲循環的退出條件
    argv[0] = Pointer<Utf8>.fromAddress(0);

    /// 定義一個二級指針,用來保存當前終端的環境信息,這個二級指針對應C語言中的二維數組
    Pointer<Pointer<Utf8>> envp;

    ///
    final Map<String, String> environment = <String, String>{};
    environment.addAll(Platform.environment);

    /// 將當前App的bin目錄也添加進這個環境變量
    environment['PATH'] =
        '${EnvirPath.filesPath}/usr/bin:' + environment['PATH'];

    /// 申請內存空間,空間數爲列元素個數加1,最後的空間用來設置空指針,好讓原生的循環退出
    envp = allocate(count: environment.keys.length + 1);

    /// 將Map內容拷貝到二維數組
    for (int i = 0; i < environment.keys.length; i++) {
      envp[i] = Utf8.toUtf8(
          '${environment.keys.elementAt(i)}=${environment[environment.keys.elementAt(i)]}');
    }

    /// 末元素賦值空指針
    envp[environment.keys.length] = Pointer<Utf8>.fromAddress(0);

    /// 定義一個指向int的指針
    /// 是C語言中經常使用的方法,指針爲雙向傳遞,能夠由調用的函數來直接更改這個值
    final Pointer<Int32> processId = allocate();

    /// 初始化爲0
    processId.value = 0;

    /// shPath爲須要C Native 執行的程序路徑
    /// 由終端的特性,這個命令通常是sh或者bash或其餘相似的程序
    /// 而且通常不帶參數,因此上面的argv爲空
    String shPath;

    /// 即便是在安卓設備上,sh也是能在環境變量中找到的
    /// 因爲在App的數據目錄中可能會存在busybox連接出來的sh,它與系統自帶的sh存在差別
    /// 若是直接執行sh就會優先執行數據目錄的sh,因此指定爲/system/bin/sh
    if (Platform.isAndroid)
      shPath = '/system/bin/sh';
    else
      shPath = 'sh';
    createSubprocess(
      Utf8.toUtf8(''),
      Utf8.toUtf8(shPath),
      Utf8.toUtf8(
          Platform.isAndroid ? '/data/data/com.nightmare/files/home' : '.'),
      argv,
      envp,
      processId,
      currentPtm,
    );
    term.pid = processId.value;
    terms.add(term);
    print(processId.value);

    /// 動態申請的空間記得釋放
    free(argv);
    free(envp);
    free(processId);
複製代碼

我將這一切封裝到NitermController類裏面

NitermController代碼

NitermController類

一個Term UI頁面對應一個控制器,在控制器被建立的時候,當前終端即被建立。

其中的addListener函數就是用來UI來綁定終端獲取輸出

2.Dart對終端輸入輸出的實現

與其說對終端的輸入輸出的實現,不如理解成對文件描述符的操做

2.1 與C Native交互

看一下函數定義

typedef get_output_from_fd = Pointer<Uint8> Function(Int32);
typedef GetOutFromFd = Pointer<Uint8> Function(int);

typedef write_to_fd = Void Function(Int32, Pointer<Utf8>);
typedef WriteToFd = void Function(int, Pointer<Utf8>);
複製代碼

這兩對函數來自上一篇文章,不過多闡述

2.2 定義一個FileDescriptor類

  • 初始化一個FileDescriptor對象咱們只須要一個int,在dart端,咱們還須要一個DynamicLibrary實例。也能夠從新建立,因爲這個類目前只由NitermController所使用,因此咱們使用NitermController的DynamicLibrary實例。
  • 一個FileDescriptor綁定着一個fd,向外提供write與read函數。

完整代碼

FileDescriptor完整代碼

3. 三種經常使用終端序列的編寫

所謂的終端控制序列,就是當終端給你輸出特定的輸出的時候,它的意圖並非想要這些字符被打印到屏幕上,而是作一些特定的操做。

3.1 定義終端序列常量類

//這是終端控制序列的類
//這是終端控制序列的類
class TermControlSequences {
  // 當按下刪除鍵時終端的輸出序列
  static const List<int> deleteChar = <int>[8, 32, 8];
  // 重置終端的序列
  static const List<int> reset_term = <int>[
    27,
    99,
    27,
    40,
    66,
    27,
    91,
    109,
    27,
    91,
    74,
    27,
    91,
    63,
    50,
    53,
    104,
  ];
  // 發出蜂鳴的序列
  static const List<int> buzzing = <int>[7];
}
複製代碼

以上的序列只是在不影響我當前項目正常運行的狀況下的序列,還有不少待重寫。

3.2 控制輸出內容

特定序列的內容是不須要輸出的,我將這一切放在了NitermController的addListener函數中。

3.2.1 終端的刪除序列

當按下刪除時,終端會輸出[8,32,8]

由上篇文章可知,Dart端也是經過一個死循環不停的從終端的ptm端得到輸出,而後將每次拿到的輸出通過處理拼接到歷史輸出上。

那麼每一次拿到的輸出包含全部的對[8,32,8]都須要刪除掉,而且記錄一下包含的個數來刪除屏幕已有輸出的內容。

相關代碼

final int deleteNum = RegExp(utf8.decode(TermControlSequences.deleteChar))
      .allMatches(result)
      .length;
    if (deleteNum > 0) {
    print('=====>發現 $deleteNum 對刪除字符的序列');
    result = result.replaceAll(RegExp(utf8.decode(TermControlSequences.deleteChar)), '');
    termOutput = termOutput.substring(0, termOutput.length - deleteNum);
}
複製代碼

其中result是某一次得到的輸出,termOutput是整個終端的輸出

3.2.2 終端的重置序列

當鍵入reset命令後,終端會向屏幕輸出[ 27, 99, 27, 40, 66, 27, 91, 109, 27, 91, 74, 27, 91, 63, 50, 53, 104, ];

當某一次的輸出包含這組序列,那麼屏幕已有的內容即立馬清空,但這組序列緊跟的其餘內容會繼續輸出

相關代碼

final bool hasRest =
  result.contains(utf8.decode(TermControlSequences.reset_term));
  print('hasRest====>$hasRest');
  if (hasRest) {
      termOutput = '';
      result =
      result.replaceAll(utf8.decode(TermControlSequences.reset_term), '');
  }
複製代碼

很麻煩的是這組序列不能使用RegExp來從某次的輸出查找,會編碼失敗。

3.2.3 終端的蜂鳴

在一些狀況終端會發出蜂鳴提示用戶

例如在當前終端用戶輸入的內容已經刪除完的時候,咱們再重複按下刪除鍵,終端會輸出字符\b,這個字符若是顯示到屏幕會有一個小小的空格,這固然不是咱們想要的。

當終端輸出序列[7]時,此時[7]就爲某次的所有序列

相關代碼

if (result == utf8.decode(TermControlSequences.buzzing)) {
    //沒有內容能夠刪除時,會輸出‘\b’,終端發出蜂鳴的聲音以來提示用戶
    print('=====>發出蜂鳴');
    continue;
}
複製代碼

4.Flutter終端的UI

4.1 Widget的選擇

終端並非簡單的黑白

當鍵入如下命令

echo -e "\\033[1;34m Nightmare \\033[0m"
複製代碼

他會是藍色的字體,在mac上表現爲紫色。

因此須要一個RichText。再因爲終端是一個能夠滑動的列表,因此RichText的上層組件是ListView,而且咱們須要在輸出到來的同時須要控制ListView及時的滑動到底部。

4.2 主題修改

只針對背景顏色,我爲這個終端適配了三套主題,分別是manjaro,termux,macos。

詳細見源碼

更改主題

在構造NitermController的時候給一個指定參數。

NitermController(
  theme: NitermThemes.manjaro,
)
複製代碼

4.3 獲取用戶的輸入

因爲整個頁面選擇了RichText,那麼咱們是否是可使用WidgetSpan在屏幕輸出的末尾添加一個文本輸入框呢?

在我反覆的嘗試以後發現這種並不友好。

因此咱們用一個ListView來包含上面的Widget與一個文本輸入框。

它看起來就是這樣:

隨後咱們將TextField的全部顏色設置爲透明

4.3.1 ctrl鍵的識別

由上面幾張圖能夠發現我實際上是增長了下面4個按鈕,最後通過反覆的嘗試得知,標準終端在按下ctrl鍵後,以後的按鍵再也不輸入它本來對應的字符,而是當前字符對應的ascii-64

4.3.2 斷定輸入仍是刪除

爲了兼容以後終端對光標的控制,我使用editingController.selection.end與保存的輸入位置來斷定

若是當前光標的位置比以前要大,那麼只須要把當前光標所在的字符輸入終端。

反之,咱們則向終端輸入ascii值爲127的字符,表明刪除。

4.3.3 輸入、刪除、ctrl鍵的識別代碼

if (editingController.selection.end > textSelectionOffset) {
    currentInput = strCall[editingController.selection.end - 1];
    if (isUseCtrl) {
        nitermController.write(String.fromCharCode(
        currentInput.toUpperCase().codeUnits[0] - 64));
        isUseCtrl = false;
        setState(() {});
    } else {
        nitermController.write(currentInput);
    }
} else {
    nitermController.write(String.fromCharCode(127));
}
複製代碼

4.4 生成富文本組件

其實嚴格說着一部分也屬於終端序列的重寫,但它直接影響到UI的顯示,因此移動到了這兒。

爲了實現徹底的業務邏輯與UI的分離,咱們依舊交給NitermController

咱們須要實現如下的效果

他的原理與

echo -e "\\033[1;34m Nightmare \\033[0m"
複製代碼

是同樣的

這一部分比較考個人算法,這部分的代碼能夠說寫得極爛。

當咱們不編寫這部分的序列

算法大體就出來了

  • 將整個字符串根據'\033['分割開,對應的unitsCode是[27, 91]

\033是esc的8進制

  • 根據首元素的數值來爲這部分輸出設置TextSpan

這部分的代碼太長,詳細見NitermController的buildTextSpan函數

看一下目前被我重寫的部分

當我執行

echo -e "\033[0;30m ------Nightmare------ \033[0m"
/* */
echo -e "\033[0;37m ------Nightmare------ \033[0m"
/* */
echo -e "\033[1;30m ------Nightmare------ \033[0m"
/* */
echo -e "\033[1;37m ------Nightmare------ \033[0m"
echo -e "\033[4;30m ------Nightmare------ \033[0m"
/* */
echo -e "\033[4;37m ------Nightmare------ \033[0m"
echo -e "\033[7;30m ------Nightmare------ \033[0m"
/* */
echo -e "\033[7;37m ------Nightmare------ \033[0m"

複製代碼

預覽

也就是支持

  • 顏色顯示
  • 顏色高亮
  • 字體下劃線
  • 顏色反轉

5. 多終端的管理與建立

咱們使用香噴噴的Provider,先觀察Termux的多終端處理。

能夠看出每一個終端的屏幕內容是保留下來的。因此咱們狀態中須要共享的數據就是NitermController,

5.1 定義ChangeNotifier

class NitermNotifier extends ChangeNotifier {
  final List<NitermController> _controlls = <NitermController>[
    NitermController(),
  ];
  List<NitermController> get controlls => _controlls;
  void addNewTerm() {
    _controlls.add(NitermController());
    notifyListeners();
  }
}
複製代碼

狀態被建立的時候默認存在一個終端。

5.2 使用狀態管理

代碼

class NitermParent extends StatefulWidget {
  @override
  _NitermParentState createState() => _NitermParentState();
}

class _NitermParentState extends State<NitermParent> {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<NitermNotifier>(
      create: (_) => NitermNotifier(),
      child: Builder(
        builder: (BuildContext c) {
          final NitermNotifier nitermNotifier = Provider.of<NitermNotifier>(c);
          return Stack(
            children: <Widget>[
              PageView.builder(
                itemCount: nitermNotifier.controlls.length,
                itemBuilder: (BuildContext c, int i) {
                  return Niterm(
                    nitermController: nitermNotifier.controlls[i],
                  );
                },
              ),
            ],
          );
        },
      ),
    );
  }
}
複製代碼

最後效果預覽

這部分的代碼在example

到這一個極其簡陋的Flutter終端模擬器實現了。待持續優化。

6. 終端集成擴展😑

在極低的機率下你若是須要集成這個終端模擬器,例如你想開發一個Flutter版的VS code?

6.1 直接使用

prebuilt_app下有android/linux/mac的安裝包或執行文件

6.2示例

example下有一個多終端的簡單例子,它可以直接運行在安卓設備上。

6.3 現有項目集成

6.3.1 添加依賴

flutter_terminal:
    git:
      url: git://github.com/Nightmare-MY/flutter_terminal.git
複製代碼

6.3.2 添加so庫

目前我還沒可以讓此這個包可以直接被項目集成,因此你須要將prebuilt_so下對應平臺的動態庫複製到程序能獲取到的地方。

android項目直接將對應設備的libterm.so放安卓端的libs文件夾便可

6.3.4 導入包

import 'package:flutter_terminal/flutter_terminal.dart';
複製代碼

6.3.5 更改so庫路徑

集成到安卓無需更改,只須要添加so庫

NitermController.libPath='你將so放到的路徑'
複製代碼

放在當前項目能獲取到的地方

注意!!!

  • 目前這個包還在測試階段,裏面還有大量的print輸出,也請不要集成正式上線的項目。

擴展的函數

我爲controll新增了一個異步函數,以下

Future<void> defineTermFunc(String func) async {
    print('定義函數中...');
    String cache = '';
    addListener((String output) {
      cache = output;
      print('output=====>$output');
    });
    print('建立臨時腳本...');
    await File('${EnvirPath.binPath}/tmp_func').writeAsString(func);
    write(
        "source ${EnvirPath.binPath}/tmp_func\nrm -rf ${EnvirPath.binPath}/tmp_func\necho 'define_func_finish'\n");
    while (!cache.contains('define_func_finish')) {
      await Future<void>.delayed(const Duration(milliseconds: 100));
    }
    termOutput = '';
    removeListener();
  }
複製代碼

若是你須要終端爲你執行大量的自動化代碼,但又不想這部分代碼被用戶所看見。能夠利用shell的函數編程。

例如:

String func= ''' function CustomFunc(){ echo *** } '''
NitermController controller = NitermController();
await controller.defineTermFunc(func);

// 僞代碼
// push ---->
Niterm(
    controller: controller,
    script: 'CustomFunc',
),
複製代碼

7. 效果預覽🧐

Android平臺

mac平臺

沒看錯,這不是自帶的終端,右上角有個debug

Linux平臺

左側爲自帶終端,顯示效果還頗有問題,字體存在亂碼。

8. 如何編譯終端的so庫🤔

在開源的外層文件有一個Niterm文件夾,它就是咱們使用的C native源碼。

Niterm文件夾

mac/Linux平臺

編譯

使用外層的CMakeFileList配置

mkdir build
cd build
camke ..
make
複製代碼

最後在build目錄找到對於應的so庫。

更改配置

android

使用文件夾自帶的編譯腳本進行交叉編譯。

mac

因爲mac端的沙盒權限,終端就沒法訪問到其餘路徑,因此你須要去xcode開啓權限訪問,讓dylib文件放在一個終端能夠有讀權限的地方。而後更改NitermController中的默認mac的動態庫的路徑。

Linux

編譯好so庫後在你的執行程序的同級建立一個lib目錄,而且確保so庫的名稱爲libterm.so便可。對應查看Controller代碼。

Windows

逝世🤣 。

windows中若是能找到dup2這一個函數的移植,而且咱們虛擬構造一個ptmx特性的文件,也許可行呢?就是資料太少了,照vs code等在win端的表現,確定是可行的,但無具體實現參考資料。

結語

  • 一切皆在代碼😑 。

發現垃圾代碼請偷偷告訴我

  • 關於scrcpy與本文的終端兩部分的代碼能夠參考的資料都比較少,因此我都記不請花了我多少時間了。
  • 這篇文章帶優化代碼耗時好幾天,你給的贊就是對個人支持。
  • 任何問題評論區留言,我會盡我所能的解決你的問題。

地址---->flutter_terminal

上次的開源庫後來整合了新的東西,此次的是獨立的。

相關文章
相關標籤/搜索