我仍是那個成天用祖傳代碼的夢魘獸🤫 。linux
我夢某人又來了,說了去複習期末考試的期間,這已是第三篇文章了,最近因爲項目對該部分的需求擴大,因此我抽了一整下午的時間來優化這部分的代碼。android
一切的原由都源於個人我的項目中須要用到完整的終端模擬器。ios
而我的項目的UI是純Flutter的項目,不涉及任何原生的頁面,若是須要集成一個終端模擬器,那麼:git
我我的項目使用Flutter的初心並非跨ios,而是跨平臺到pc,因此這還有得選嗎🤣 。github
上一篇文章寫得匆忙,上篇僅僅是對終端模擬器底層實現原理的解析。算法
這篇咱們講如何將它對接到Flutter,而且在極少代碼的改動下,同時跨mac/linux/android平臺。shell
上篇文章-->開源一個Flutter編寫的完整終端模擬器macos
上篇的的開源地址是集成它的項目地址編程
開源地址在最後windows
由上篇文章能夠得知,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的包
typedef create_ptm = Int32 Function(Int32 row, Int32 column);
複製代碼
名字不要大寫,由於它是一個native function
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 *
即綁定過程
final CreatePtm createPtm = getPtmIntPointer.asFunction<CreatePtm>();
複製代碼
final int currentPtm = createPtm(300, 300);
複製代碼
這行代碼被執行的時候,在對應的設備的/dev/pts/目錄就立馬會多出一個文件,因此這也是檢測是函數否調用成功。 300,300是終端模擬器的寬高,隨意寫的一個值,它的數值會影響終端換行符的位置,這部分尚未作研究。仍是因爲目前我沒法控制字體換行的時機。
因此到這終端對就建立好了
能夠看到這個函數須要的參數比較多,因此對應的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類裏面
其中的addListener函數就是用來UI來綁定終端獲取輸出
與其說對終端的輸入輸出的實現,不如理解成對文件描述符的操做
看一下函數定義
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>);
複製代碼
這兩對函數來自上一篇文章,不過多闡述
所謂的終端控制序列,就是當終端給你輸出特定的輸出的時候,它的意圖並非想要這些字符被打印到屏幕上,而是作一些特定的操做。
//這是終端控制序列的類
//這是終端控制序列的類
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];
}
複製代碼
以上的序列只是在不影響我當前項目正常運行的狀況下的序列,還有不少待重寫。
特定序列的內容是不須要輸出的,我將這一切放在了NitermController的addListener函數中。
當按下刪除時,終端會輸出[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是整個終端的輸出
當鍵入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來從某次的輸出查找,會編碼失敗。
在一些狀況終端會發出蜂鳴提示用戶
例如在當前終端用戶輸入的內容已經刪除完的時候,咱們再重複按下刪除鍵,終端會輸出字符\b,這個字符若是顯示到屏幕會有一個小小的空格,這固然不是咱們想要的。
當終端輸出序列[7]時,此時[7]就爲某次的所有序列
if (result == utf8.decode(TermControlSequences.buzzing)) {
//沒有內容能夠刪除時,會輸出‘\b’,終端發出蜂鳴的聲音以來提示用戶
print('=====>發出蜂鳴');
continue;
}
複製代碼
終端並非簡單的黑白
當鍵入如下命令
echo -e "\\033[1;34m Nightmare \\033[0m"
複製代碼
他會是藍色的字體,在mac上表現爲紫色。
因此須要一個RichText。再因爲終端是一個能夠滑動的列表,因此RichText的上層組件是ListView,而且咱們須要在輸出到來的同時須要控制ListView及時的滑動到底部。
只針對背景顏色,我爲這個終端適配了三套主題,分別是manjaro,termux,macos。
詳細見源碼
在構造NitermController的時候給一個指定參數。
NitermController(
theme: NitermThemes.manjaro,
)
複製代碼
因爲整個頁面選擇了RichText,那麼咱們是否是可使用WidgetSpan在屏幕輸出的末尾添加一個文本輸入框呢?
在我反覆的嘗試以後發現這種並不友好。
因此咱們用一個ListView來包含上面的Widget與一個文本輸入框。
它看起來就是這樣:
隨後咱們將TextField的全部顏色設置爲透明
由上面幾張圖能夠發現我實際上是增長了下面4個按鈕,最後通過反覆的嘗試得知,標準終端在按下ctrl鍵後,以後的按鍵再也不輸入它本來對應的字符,而是當前字符對應的ascii-64
爲了兼容以後終端對光標的控制,我使用editingController.selection.end與保存的輸入位置來斷定
若是當前光標的位置比以前要大,那麼只須要把當前光標所在的字符輸入終端。
反之,咱們則向終端輸入ascii值爲127的字符,表明刪除。
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));
}
複製代碼
其實嚴格說着一部分也屬於終端序列的重寫,但它直接影響到UI的顯示,因此移動到了這兒。
爲了實現徹底的業務邏輯與UI的分離,咱們依舊交給NitermController
咱們須要實現如下的效果
他的原理與
echo -e "\\033[1;34m Nightmare \\033[0m"
複製代碼
是同樣的
這一部分比較考個人算法,這部分的代碼能夠說寫得極爛。
當咱們不編寫這部分的序列
算法大體就出來了
\033是esc的8進制
這部分的代碼太長,詳細見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"
複製代碼
咱們使用香噴噴的Provider,先觀察Termux的多終端處理。
能夠看出每一個終端的屏幕內容是保留下來的。因此咱們狀態中須要共享的數據就是NitermController,class NitermNotifier extends ChangeNotifier {
final List<NitermController> _controlls = <NitermController>[
NitermController(),
];
List<NitermController> get controlls => _controlls;
void addNewTerm() {
_controlls.add(NitermController());
notifyListeners();
}
}
複製代碼
狀態被建立的時候默認存在一個終端。
代碼
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終端模擬器實現了。待持續優化。
在極低的機率下你若是須要集成這個終端模擬器,例如你想開發一個Flutter版的VS code?
在prebuilt_app下有android/linux/mac的安裝包或執行文件
在example下有一個多終端的簡單例子,它可以直接運行在安卓設備上。
flutter_terminal:
git:
url: git://github.com/Nightmare-MY/flutter_terminal.git
複製代碼
目前我還沒可以讓此這個包可以直接被項目集成,因此你須要將prebuilt_so下對應平臺的動態庫複製到程序能獲取到的地方。
android項目直接將對應設備的libterm.so放安卓端的libs文件夾便可
import 'package:flutter_terminal/flutter_terminal.dart';
複製代碼
集成到安卓無需更改,只須要添加so庫
NitermController.libPath='你將so放到的路徑'
複製代碼
放在當前項目能獲取到的地方
我爲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',
),
複製代碼
沒看錯,這不是自帶的終端,右上角有個debug
左側爲自帶終端,顯示效果還頗有問題,字體存在亂碼。
在開源的外層文件有一個Niterm文件夾,它就是咱們使用的C native源碼。
使用外層的CMakeFileList配置
mkdir build
cd build
camke ..
make
複製代碼
最後在build目錄找到對於應的so庫。
使用文件夾自帶的編譯腳本進行交叉編譯。
因爲mac端的沙盒權限,終端就沒法訪問到其餘路徑,因此你須要去xcode開啓權限訪問,讓dylib文件放在一個終端能夠有讀權限的地方。而後更改NitermController中的默認mac的動態庫的路徑。
編譯好so庫後在你的執行程序的同級建立一個lib目錄,而且確保so庫的名稱爲libterm.so便可。對應查看Controller代碼。
逝世🤣 。
windows中若是能找到dup2這一個函數的移植,而且咱們虛擬構造一個ptmx特性的文件,也許可行呢?就是資料太少了,照vs code等在win端的表現,確定是可行的,但無具體實現參考資料。
發現垃圾代碼請偷偷告訴我
地址---->flutter_terminal
上次的開源庫後來整合了新的東西,此次的是獨立的。