Flutter 终端模拟器探索篇(三)| 原理解析与集成

2,958 阅读6分钟

这篇相对比较鸡肋,有兴趣开之后的文章(编辑于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代码

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

上次的开源库后来整合了新的东西,这次的是独立的。