我仍是那个成天用祖传代码的梦魇兽🤫 。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的多终端处理。
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
上次的开源库后来整合了新的东西,此次的是独立的。