• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

Android消息机制一程序和交互

武飞扬头像
开中断
帮助40

1. “Interactive” 什么是可交互的。

我们会通过GUI来使用我们的操作系统,GUI即图形用户界面,它更加地友好、易用,极大程度上地降低了使用计算机的学习成本和扩展了家用计算机的用途,使用GUI,离不开的就是一些外设,例如:鼠标(触摸板)、键盘、触摸屏等等。它们承担着用户对计算机操作的输入,而音响、显示器则承担着输出,这一来一往便是“交互”,这个交互的前提,就是我们的用户界面:GUI。

时光倒流,我们先抛开GUI这么个现代的产物,回到只有字符终端的年代。我们和程序、计算机交互的唯一方式,就是使用键盘输入字符,然后在显示字符的显示器上输出内容。例如通过键盘输入到终端,然后通过终端打印运行的结果。在那个年代,交互似乎没有那么多、那么麻烦的问题要处理,我们只需要add 1 2,1和2就会被传入到程序的main函数的参数中,我们就可以利用add这么个程序去计算1 2的值,然后输出3这个结果,这也是可交互的。1、2,就是我们的输入,3就是输出,这就是一次交互。

但是通常来说,3输出之后,main函数返回,程序就结束了,这种程序并不是可以持续交互的,因为它只能读入一次输入,做出一次输出,如果想多次使用,你就得启动多次程序的实例。

人们想到,从标准输入流读入字符,然后用一个while循环,等等标准输入流来接收指令,根据输入的字符来做出反馈,这也就是我们的Shell的运行方式:

string cmd;
while(true){
     cmd = readLine()

     // 获取参数的第一个内容,例如add 1 2 获取的就是add字符
     switch(command.firstArg()){
      case "cd"->switch_work_dir();// 切换工作目录
      case "exit"->exit();// 退出shell
      case "add" -> add(cmd);// 假设shell有这么个内置命令,用来计算第一个参数和第二个参数相加的,实际上是没有的
      ……
      else -> print_err();// 没有这个命令
    }
}

void add(string cmd){
   val arg1 = getArg(cmd);
   val arg2 = getArg(cmd);
   print(arg1   arg2);
}

这样一来,程序就变得可以持续交互了,如果我们想多次执行add指令,只需要在终端中,输入add 1 2即可进行一次计算,完成后程序会回到cmd = readLine()这么一行阻塞, 然后一旦标准输入流中有了输入,此处就被唤醒,继续执行用户的输入,这便是一个很简单的可交互程序。

所以,一个程序可交互的关键之处,就在于:

  • 等待用户输入;
  • 由输入的事件决定下一步的操作;
  • 继续等待用户输入。

由此就构成了一个闭环,循环不退出,程序也不退出;循环退出了,程序也退出了。

2. 看一看Shell的实现

2.1 Shell和终端

GUI我们天天在使用自然不用多说,Android、Mac就是一个支持GUI的操作系统,但是不代表它只能运行GUI应用,我们可以通过类似SSH等方式登录到操作系统之上使用,这样就是在非GUI环境下使用操作系统了。

而此处的终端一般指的是模拟终端,用来模拟有一个用户现在连接到该计算机,在Mac或者Ubuntu上终端通常以Terminal软件的形式存在:

终端只会进行模拟用户的输入和输出,因为现在这一类的发行版操作系统基本上都是以GUI交互为主了,所以模拟终端可以看做是一个命令行的模拟工具和交互的入口

而Shell则是一个跑在远程机器上的一个应用程序,它的作用是解析用户输入、运行程序、得到用户输出最后返回给模拟终端,Shell通常指的是一类程序,例如我们常用的bash、zsh都是一个Shell,即用户命令解析工具。

Shell所做的事情,就是在源源不断地解析用户输入,然后处理用户指令,调用对应的程序、操作,然后继续等待用户指令,它便是最早的可持续交互应用程序之一。

2.2 Xv6 Shell.c的实现

Xv6是由麻省理工学院(MIT)为操作系统工程的课程(代号6.828),开发的一个教学目的的操作系统。Xv6是在x86处理器上(x即指x86)用ANSI标准C重新实现的Unix第六版(Unix V6,通常直接被称为V6)。

int
main(void)
{
  static char buf[100];
  int fd;
  
 // 保证只打开012三个流,其余的流全部关闭掉
  while((fd = open("console", O_RDWR)) >= 0){
    if(fd >= 3){
      close(fd);
      break;
    }
  }
  
  // Read and run input commands.
  while(getcmd(buf, sizeof(buf)) >= 0){
    if(buf[0] == 'c' && buf[1] == 'd' && buf[2] == ' '){
      // Clumsy but will have to do for now.
      // Chdir has no effect on the parent if run in the child.
      buf[strlen(buf)-1] = 0;  // chop \n
      if(chdir(buf 3) < 0)
        printf(2, "cannot cd %s\n", buf 3);
      continue;
    }
    if(fork1() == 0)
      runcmd(parsecmd(buf));
    wait();
  }
  exit();
}

因为Xv6是用于教学的Unix操作系统,所以整体代码非常干净,也很适合阅读,它的Shell的实现,首先是尝试打开了一个console,第一部分的代码主要是确保:fd = 0、1、2的三个标准流被打开,这三个流最终都指向了console,也就是控制终端,分别是输入、输出和错误流。

此后,第二部分的代码会不断地从标准输入流中读取输入,存在buf中,然后去解析指令。例如cd指令就是在这里解析的,然后调用chdir来修改当前的工作目录。因为没有其他指令了,就去调用fork去创建一个子进程,并且在子进程中,先解析命令,然后去运行新的程序(如果命令正确的话)。而父进程则调用wait等待它的执行,此时Shell就被挂起,然后处理器去执行fork出来的子进程,子进程执行完成后,再接着回来执行Shell的循环。

这便是一个真实的、可用的,交互程序的实现,我们可以看看getcmd是如何实现的。在Xv6中,getcmd会调用到read这个系统调用,对应的系统调用是sys_read,调用号是5,最终函数会走向file.c中的fileread函数中:

// Read from file f.
int
fileread(struct file *f, char *addr, int n)
{
  int r;

  if(f->readable == 0)
    return -1;
  if(f->type == FD_PIPE)
 return piperead(f->pipe, addr, n);
 if (f->type == FD_INODE){
   ilock(f->ip);
 if ((r = readi(f->ip, addr, f->off, n)) > 0 )
f->off  = r;
iunlock(f->ip);
    return r;
  }
  panic("fileread");
}

根据文件类型的不同,读取的方式也不同,首先是管道,读取调用的是piperead方法;然后是普通文件,使用的是先调用ilock方法,尝试给iNode加锁,锁上了之后在调用readi去读文件,但不论是pipe还是inode,最终都离不开一个东西:循环,循环到文件可用的时候或者是读出新数据的时候,跳出循环即可读取数据,然后唤醒等待的程序:

int
piperead(struct pipe *p, char *addr, int n)
{
  // ……
  while(p->nread == p->nwrite && p->writeopen){  //DOC: pipe-empty
    // ……
    sleep(&p->nread, &p->lock); //DOC: piperead-sleep
  }
  // ……
  wakeup
}


void
ilock(struct inode *ip)
{
  // ……
  while(ip->flags & I_BUSY){
    sleep(ip, &icache.lock);
  }
  // ……
  wakeup
}

sleep的作用就是让程序进入sleep,并调用sched(),重新进入调度,最终会走向一段汇编代码,我们看看注释:

# Save current register context in old
# and then load register context from new.

就是进程的上下文切换。

这便是getscmd时,程序的阻塞,无非就是调用getscmd -> 系统调用读 -> 有内容则返回,没有内容则阻塞,阻塞的本质是一个循环,循环内部不断地去自旋,然后内部调用sched去调度来释放CPU(进程的上下文的切换)。

  1. 你也不用担心程序如果不去调用sched会发生什么,因为这些都是read系统调用的代码,都是系统内置的。只要你调用了read就会受到操作系统read实现方式的约束,并不会因为外部的调用而导致原有的内部实现出现问题。
  2. 这也只是Xv6的处理方式,其他的操作系统一般会使用阻塞队列 中断来处理读取阻塞,比如Android便采用了epoll机制,当阻塞发生的时候,程序进入阻塞队列,处理机调度给其它程序,然后等待IO,IO完成后,就会产生中断通知CPU,激活阻塞队列找到你的进程,然后重新移入就绪态进行调度执行。

3. 忙碌的主线程

我们可以使用C语言直接写一个程序,仿照Shell的形式让他可以交互起来,然后启动一个线程去下载文件,通常来说,子线程在下载文件,并不会影响到主线程的执行,程序的主线程仍然可以接受命令和解析命令,但是异步任务完成后,我们一定需要某种机制去通知主线程。通常是在循环中,检查一下是否有消息:

string cmd;

result = null;

void download(url){
  // 下载
  result = download(url);
}


while(true){
     cmd = readLine()
     if(result != null){
         // 处理消息
         result.处理();
         ......
         // 处理完成后进入下一次循环
         result = null;
         continue;
     }
     // ……
    }
}

然后告知用户下载结果。如果只有一个异步任务我们可以用一个变量,但是如果有多个任务我们可能就得考虑使用一个数组或者链表来存储对应的结果了:

result = [];

onSuccess((){
  result.add(true);
});

而GUI面临的问题,却更加复杂。

因为GUI对主线程的事件输入来源将更加广泛,以Android为例,例如上述提到的异步任务的结果,我们需要提交给主线程来做视图更新;又例如我们的视图更新本身,也要在主线程执行,只不过它有很高的优先级;触摸、滑动、点按事件也需要也主线程统一下发执行,包括页面跳转、一些生命周期函数的执行等等,都需要主线程来执行。

3.1 消息队列

如果没有一个专门的结构来存储这一系列需要主线程执行的信息和操作,那么面对猝发的一系列的消息,主线程必然是分身乏术,所以消息队列应运而生。

因为主线程在一个时刻只能处理一条消息,比如渲染画面,比如下发事件,比如处理异步事件的结果等等,它并不能同时处理,所以,这些消息就按照「先后 重要性」顺序依次排开,在一个队列中,主线程不断地从队列中去取消息来执行,这样做到了宏观上主线程能够处理多个消息的「假象」。同样地,这也就是意味着几乎任何消息的都是有延迟的,比如此时主线程真正处理一个异步网络的回调:

val result =  download(url);

callback(result);// 预计耗时100ms

如果此时有一个Vsync信号进来,通知去渲染视图,但是这个渲染信号必须等到这个异步网络回调处理完成之后,才有可能在循环中被取出,从而得到执行,这样一来100ms之内的约6个帧就无法被渲染出来,导致视图停留在当前状态6x16.6ms 至少100ms,这会造成卡顿;如果再严重点,延迟了数秒,Android系统甚至会抛出一个未响应。这也是不建议在主线程进行耗时计算的原因。

因为事件之间有轻重缓急,所以所有的事件也不是按照加入的顺序执行的,渲染这类的、用户具有高可感知性的任务通常会保证优先执行;而其他的任务则可以稍等。而任务一旦开始执行是无法被中断的,这就需要一个调度的时机。

聪明的你一定想到了,这个时机一定在循环当中,只有我们去读取消息的时候,主线程才知道是否有紧急任务、是否去优先调度紧急任务,其余时间主线程几乎都在各种各样的Message的Runnable或者Message预置的一些操作中不停地做操作。

在Android中,可切换的粒度便是一个消息,仅当一个消息Runnable和消息对应的事件执行完成之后,才有可能让下一个紧急的任务执行,并不会发生网络回调执行了一半,就被紧急事件视图渲染中断的情况;但是如果此时另外一个网络回调的任务排在队列的最前端,此时又有一个渲染任务被加入队列,那么就会优先执行更为紧急的渲染任务,具体的实现我们后面再谈。

4. 小结

以上便是我们使用命令和计算机交互的一个简单过程,总结下来,我们离不开的几个点,就是:

  1. main函数
  2. 等待用户输入
  3. 根据用户输入,执行下一步操作
  4. 继续等待用户输入

不难发现,2到4,4再到2构成了一个「循环」,这个循环,便是程序可持续交互的关键。

同样,我们也了解了主线程在用户交互中重要的地位,特别是在GUI中,主线程要『同时』处理很多的任务,以Android为例,首当其冲的便是频繁的视图更新,如果UI不断地在变化,那么16.6ms(60帧)就会产生一个Vsync信号来更新视图。

这些任务并不能放到子线程中执行,因为视图的更新是非常频繁的,如果采用放开多线程更新视图,如果不做同步处理,那么可能会因为使用不规范导致绘制的问题;如果做了同步处理,势必又会因为锁导致性能的下降。

然后我们介绍了消息队列,那么消息队列解决了什么问题呢?归根结底,线程在一个时刻只能在执行一个操作,而由于源源不断的事件来源,可能会让我们现有的程序执行流在面对各种各样事件的时候分身乏术。借助于Java/Kotlin语言的特性,函数可以以一个Runnable或者一个Lambda表达式的形式存在,而每个函数,就是线程执行的一个单元, 只有在一个执行单元完成之后,才可能去执行其他单元的程序,消息队列就是存储这类执行流的结构,他就是保证Android App可持续交互的“功臣”。

~end

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/tanffaai
系列文章
更多 icon
同类精品
更多 icon
继续加载