Flutter终端模拟器实现-原理解析与集成

前言

  • 我仍是那个成天用祖传代码的梦魇兽🤫 。linux

  • 我梦某人又来了,说了去复习期末考试的期间,这已是第三篇文章了,最近因为项目对该部分的需求扩大,因此我抽了一整下午的时间来优化这部分的代码。android

  • 一切的原由都源于个人我的项目中须要用到完整的终端模拟器。ios

而我的项目的UI是纯Flutter的项目,不涉及任何原生的页面,若是须要集成一个终端模拟器,那么:git

  • 1.我能够用PlatformView对接Termux开源的View。
  • 2.用Flutter重构一个跨平台的终端模拟器

我我的项目使用Flutter的初心并非跨ios,而是跨平台到pc,因此这还有得选吗🤣 。github

上一篇文章写得匆忙,上篇仅仅是对终端模拟器底层实现原理的解析。算法

这篇咱们讲如何将它对接到Flutter,而且在极少代码的改动下,同时跨mac/linux/android平台。shell

上篇文章-->开源一个Flutter编写的完整终端模拟器macos

上篇的的开源地址是集成它的项目地址编程

本篇主要涉及

  • 1.Dart建立终端
  • 2.Dart对终端输入输出的实现
  • 3.终端序列的重写
  • 4.Flutter终端的显示
  • 5.多终端的管理与建立

开源地址在最后windows

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

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

相关文章
相关标签/搜索