这篇相对比较鸡肋,有兴趣开之后的文章(编辑于2021.04.18)
前言
-
我还是那个整天用祖传代码的梦魇兽🤫 。
-
我梦某人又来了,说了去复习期末考试的期间,这已经是第三篇文章了,最近由于项目对该部分的需求扩大,所以我抽了一整下午的时间来优化这部分的代码。
-
一切的起因都源于我的个人项目中需要用到完整的终端模拟器。
而个人项目的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给我们提供的函数有两个(详见上一篇文章)
- 创建终端对
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类
一个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函数。
完整代码
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源码。
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
上次的开源库后来整合了新的东西,这次的是独立的。