Flutter 终端模拟器探索篇(三)| 原理解析与集成
前言
我还是那个整天用祖传代码的梦魇兽 🤫 。
我梦某人又来了,说了去复习期末考试的期间,这已经是第三篇文章了,最近由于项目对该部分的需求扩大,所以我抽了一整下午的时间来优化这部分的代码。
一切的起因都源于我的个人项目中需要用到完整的终端模拟器。
而个人项目的 UI 是纯 Flutter 的项目,不涉及任何原生的页面,如果需要集成一个终端模拟器,那么:
- 1.我可以用 PlatformView 对接 Termux 开源的 View。
- 2.用 Flutter 重构一个跨平台的终端模拟器
我个人项目使用 Flutter 的初心并不是跨 ios,而是跨平台到 pc,所以这还有得选吗 🤣 。
上一篇文章写得匆忙,上篇仅仅是对终端模拟器底层实现原理的解析。
这篇我们讲如何将它对接到 Flutter,并且在极少代码的改动下,同时跨 mac/linux/android 平台。
上篇文章–>开源一个 Flutter 编写的完整终端模拟器
上篇的的开源地址是集成它的项目地址
本篇主要涉及
- 1.Dart 创建终端
- 2.Dart 对终端输入输出的实现
- 3.终端序列的重写
- 4.Flutter 终端的显示
- 5.多终端的管理与创建
开源地址在最后
1.Dart 创建终端
由上篇文章可以得知,C Native 给我们提供的函数有两个(详见上一篇文章)
- 创建终端对
1 | int create_ptm(int rows,int columns) |
- 在已获得的终端对执行子程序
1 | 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 的对应声明
1 | typedef create_ptm = Int32 Function(Int32 row, Int32 column); |
名字不要大写,因为它是一个 native function
对应 Dart 可调用的函数
1 | typedef CreatePtm = int Function(int row, int column); |
创建指向原生函数的指针
1 | final Pointer<NativeFunction<create_ptm>> getPtmIntPointer = |
dart 用泛型来表示指针指向的类型
Pointer<Int32> 对应 int *
使用上面的指针来初始化可被 dart 调用的函数
即绑定过程
1 | final CreatePtm createPtm = getPtmIntPointer.asFunction<CreatePtm>(); |
调用创建
1 | final int currentPtm = createPtm(300, 300); |
这行代码被执行的时候,在对应的设备的/dev/pts/目录就立马会多出一个文件,所以这也是检测是函数否调用成功。
300,300 是终端模拟器的宽高,随意写的一个值,它的数值会影响终端换行符的位置,这部分还没有做研究。还是由于目前我无法控制字体换行的时机。
所以到这终端对就创建好了
1.2 在已获得的终端对执行子程序
可以看到这个函数需要的参数比较多,所以对应的 dart 的代码也比较复杂
但这部分的整体套路与上面一样
对应声明
1 | typedef create_subprocess = Void Function( |
完整代码(带详细注释)
1 | // 找到在当前终端对创建子程序的原生指针,指向C语言中create_subprocess这个函数 |
我将这一切封装到 NitermController 类里面
NitermController 类
一个 Term UI 页面对应一个控制器,在控制器被创建的时候,当前终端即被创建。
其中的 addListener 函数就是用来 UI 来绑定终端获取输出
2.Dart 对终端输入输出的实现
与其说对终端的输入输出的实现,不如理解成对文件描述符的操作
2.1 与 C Native 交互
看一下函数定义
1 | typedef get_output_from_fd = Pointer<Uint8> Function(Int32); |
这两对函数来自上一篇文章,不过多阐述
2.2 定义一个 FileDescriptor 类
- 初始化一个 FileDescriptor 对象我们只需要一个 int,在 dart 端,我们还需要一个 DynamicLibrary 实例。也可以重新创建,由于这个类目前只由 NitermController 所使用,所以我们使用 NitermController 的 DynamicLibrary 实例。
- 一个 FileDescriptor 绑定着一个 fd,向外提供 write 与 read 函数。
完整代码
3. 三种常用终端序列的编写
所谓的终端控制序列,就是当终端给你输出特定的输出的时候,它的意图并不是想要这些字符被打印到屏幕上,而是做一些特定的操作。
3.1 定义终端序列常量类
1 | //这是终端控制序列的类 |
以上的序列只是在不影响我当前项目正常运行的情况下的序列,还有很多待重写。
3.2 控制输出内容
特定序列的内容是不需要输出的,我将这一切放在了NitermController的 addListener 函数中。
3.2.1 终端的删除序列
当按下删除时,终端会输出[8,32,8]
由上篇文章可知,Dart 端也是通过一个死循环不停的从终端的 ptm 端获得输出,然后将每次拿到的输出经过处理拼接到历史输出上。
那么每一次拿到的输出包含所有的对[8,32,8]都需要删除掉,并且记录一下包含的个数来删除屏幕已有输出的内容。
相关代码
1 | final int deleteNum = RegExp(utf8.decode(TermControlSequences.deleteChar)) |
其中 result 是某一次获得的输出,termOutput 是整个终端的输出
3.2.2 终端的重置序列
当键入 reset 命令后,终端会向屏幕输出[
27,
99,
27,
40,
66,
27,
91,
109,
27,
91,
74,
27,
91,
63,
50,
53,
104,
];
当某一次的输出包含这组序列,那么屏幕已有的内容即立马清空,但这组序列紧跟的其他内容会继续输出
相关代码
1 | final bool hasRest = |
很麻烦的是这组序列不能使用 RegExp 来从某次的输出查找,会编码失败。
3.2.3 终端的蜂鸣
在一些情况终端会发出蜂鸣提示用户
例如在当前终端用户输入的内容已经删除完的时候,我们再重复按下删除键,终端会输出字符\b,这个字符如果显示到屏幕会有一个小小的空格,这当然不是我们想要的。
当终端输出序列[7]时,此时[7]就为某次的全部序列
相关代码
1 | if (result == utf8.decode(TermControlSequences.buzzing)) { |
4.Flutter 终端的 UI
4.1 Widget 的选择
终端并不是简单的黑白
当键入以下命令
1 | echo -e "\\033[1;34m Nightmare \\033[0m" |
他会是蓝色的字体,在 mac 上表现为紫色。
所以需要一个RichText。再由于终端是一个可以滑动的列表,所以RichText的上层组件是ListView,并且我们需要在输出到来的同时需要控制ListView及时的滑动到底部。
4.2 主题修改
只针对背景颜色,我为这个终端适配了三套主题,分别是 manjaro,termux,macos。
详细见源码
更改主题
在构造 NitermController 的时候给一个指定参数。
1 | NitermController( |
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 键的识别代码
1 | if (editingController.selection.end > textSelectionOffset) { |
4.4 生成富文本组件
其实严格说着一部分也属于终端序列的重写,但它直接影响到 UI 的显示,所以移动到了这儿。
为了实现完全的业务逻辑与 UI 的分离,我们依旧交给 NitermController
我们需要实现以下的效果
他的原理与
1 | echo -e "\\033[1;34m Nightmare \\033[0m" |
是一样的
这一部分比较考我的算法,这部分的代码可以说写得极烂。
当我们不编写这部分的序列
算法大致就出来了
- 将整个字符串根据’\033[‘分割开,对应的 unitsCode 是[27, 91]
\033 是 esc 的 8 进制
- 根据首元素的数值来为这部分输出设置 TextSpan
这部分的代码太长,详细见NitermController的 buildTextSpan 函数
看一下目前被我重写的部分
当我执行
1 | echo -e "\033[0;30m ------Nightmare------ \033[0m" |
预览
也就是支持
- 颜色显示
- 颜色高亮
- 字体下划线
- 颜色反转
5. 多终端的管理与创建
我们使用香喷喷的 Provider,先观察 Termux 的多终端处理。
可以看出每个终端的屏幕内容是保留下来的。所以我们状态中需要共享的数据就是 NitermController,
5.1 定义 ChangeNotifier
1 | class NitermNotifier extends ChangeNotifier { |
状态被创建的时候默认存在一个终端。
5.2 使用状态管理
代码
1 | class NitermParent extends StatefulWidget { |
最后效果预览
这部分的代码在example
到这一个极其简陋的 Flutter 终端模拟器实现了。待持续优化。
6. 终端集成扩展 😑
在极低的概率下你如果需要集成这个终端模拟器,例如你想开发一个 Flutter 版的 VS code?
6.1 直接使用
在prebuilt_app下有 android/linux/mac 的安装包或执行文件
6.2 示例
在example下有一个多终端的简单例子,它能够直接运行在安卓设备上。
6.3 现有项目集成
6.3.1 添加依赖
1 | flutter_terminal: |
6.3.2 添加 so 库
目前我还没能够让此这个包能够直接被项目集成,所以你需要将prebuilt_so下对应平台的动态库复制到程序能获取到的地方。
android 项目直接将对应设备的 libterm.so 放安卓端的 libs 文件夹即可
6.3.4 导入包
1 | import 'package:flutter_terminal/flutter_terminal.dart'; |
6.3.5 更改 so 库路径
集成到安卓无需更改,只需要添加 so 库
1 | NitermController.libPath='你将so放到的路径' |
放在当前项目能获取到的地方
注意!!!
- 目前这个包还在测试阶段,里面还有大量的 print 输出,也请不要集成正式上线的项目。
扩展的函数
我为 controll 新增了一个异步函数,如下
1 | Future<void> defineTermFunc(String func) async { |
如果你需要终端为你执行大量的自动化代码,但又不想这部分代码被用户所看见。可以利用 shell 的函数编程。
例如:
1 | String func= ''' |
7. 效果预览 🧐
Android 平台
mac 平台
没看错,这不是自带的终端,右上角有个 debug
Linux 平台
左侧为自带终端,显示效果还很有问题,字体存在乱码。
8. 如何编译终端的 so 库 🤔
在开源的外层文件有一个 Niterm 文件夹,它就是我们使用的 C native 源码。
mac/Linux 平台
编译
使用外层的 CMakeFileList 配置
1 | mkdir build |
最后在 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
上次的开源库后来整合了新的东西,这次的是独立的。
Flutter 终端模拟器探索篇(三)| 原理解析与集成
http://blog.nightmare.press/2020/06/18/Flutter-终端模拟器探索篇(三)-原理解析与集成/