Android的init过程(一)

本文使用的软件版本

Android:4.2.2

Linux内核:3.1.10

在上一篇文章中介绍了init的初始化第一阶段,也就是处理各种属性。在本文将会详细分析init最重要的一环:解析init.rc文件。

init.rc 文件并不是普通的配置文件,而是由一种被称为“Android初始化语言”(Android Init Language,这里简称为AIL)的脚本写成的文件。在了解init如何解析init.rc文件之前,先了解AIL非常必要,否则机械地分析 init.c及其相关文件的源代码毫无意义。

为了学习AIL,读者可以到自己Android手机的根目录寻找init.rc文件,最好下载到本地以便查看,如果有编译好的Android源代码, 在<Android源代码根目录>out/target/product/generic/root目录也可找到init.rc文件。

AIL由如下4部分组成。

1. 动作(Actions)

2. 命令(Commands)

3. 服务(Services)

4. 选项(Options)

这4部分都是面向行的代码,也就是说用回车换行符作为每一条语句的分隔符。而每一行的代码由多个符号(Tokens)表示。可以使用反斜杠转义符在 Token中插入空格。双引号可以将多个由空格分隔的Tokens合成一个Tokens。如果一行写不下,可以在行尾加上反斜杠,来连接下一行。也就是 说,可以用反斜杠将多行代码连接成一行代码。

AIL的注释与很多Shell脚本一行,以#开头。

AIL在编写时需要分成多个部分(Section),而每一部分的开头需要指定Actions或Services。也就是说,每一个Actions或 Services确定一个Section。而所有的Commands和Options只能属于最近定义的Section。如果Commands和 Options在第一个Section之前被定义,它们将被忽略。

Actions和Services的名称必须唯一。如果有两个或多个Action或Service拥有同样的名称,那么init在执行它们时将抛出错误,并忽略这些Action和Service。

下面来看看Actions、Services、Commands和Options分别应如何设置。

Actions的语法格式如下:

on <trigger>     <command>     <command>     <command>

也就是说Actions是以关键字on开头的,然后跟一个触发器,接下来是若干命令。例如,下面就是一个标准的Action。

    on boot          ifup lo          hostname localhost          domainname localdomain  

其中boot是触发器,下面三行是command

那么init.rc到底支持哪些触发器呢?目前init.rc支持如下5类触发器。

1. boot

这是init执行后第一个被触发Trigger,也就是在 /init.rc被装载之后执行该Trigger

2. <name>=<value>

当属性<name>被设置成<value>时被触发。例如,

on property:vold.decrypt=trigger_reset_main

class_reset main

3. device-added-<path>

当设备节点被添加时触发

4. device-removed-<path>

当设备节点被移除时添加

5. service-exited-<name>

会在一个特定的服务退出时触发

Actions后需要跟若干个命令,这些命令如下:

1. exec <path> [<argument> ]*

创建和执行一个程序(<path>)。在程序完全执行前,init将会阻塞。由于它不是内置命令,应尽量避免使用exec ,它可能会引起init执行超时。

2. export <name> <value>

在全局环境中将 <name>变量的值设为<value>。(这将会被所有在这命令之后运行的进程所继承)

3. ifup <interface>

启动网络接口

4. import <filename>

指定要解析的其他配置文件。常被用于当前配置文件的扩展

5. hostname <name>

设置主机名

6. chdir <directory>

改变工作目录

7. chmod <octal-mode><path>

改变文件的访问权限

8. chown <owner><group> <path>

更改文件的所有者和组

9. chroot <directory>

改变处理根目录

10. class_start<serviceclass>

启动所有指定服务类下的未运行服务。

11 class_stop<serviceclass>

停止指定服务类下的所有已运行的服务。

12. domainname <name>

设置域名

13. insmod <path>

加载<path>指定的驱动模块

14. mkdir <path> [mode][owner] [group]

创建一个目录<path> ,可以选择性地指定mode、owner以及group。如果没有指定,默认的权限为755,并属于root用户和 root组。

15. mount <type> <device> <dir> [<mountoption> ]*

试图在目录<dir>挂载指定的设备。<device> 可以是[email protected]的形式指定一个mtd块设备。<mountoption>包括 "ro"、"rw"、"re

16. setkey

保留,暂时未用

17. setprop <name><value>

将系统属性<name>的值设为<value>。

18. setrlimit <resource> <cur> <max>

设置<resource>的rlimit (资源限制)

19. start <service>

启动指定服务(如果此服务还未运行)。

20.stop<service>

停止指定服务(如果此服务在运行中)。

21. symlink <target> <path>

创建一个指向<path>的软连接<target>。

22. sysclktz <mins_west_of_gmt>

设置系统时钟基准(0代表时钟滴答以格林威治平均时(GMT)为准)

23. trigger <event>

触发一个事件。用于Action排队

24. wait <path> [<timeout> ]

等待一个文件是否存在,当文件存在时立即返回,或到<timeout>指定的超时时间后返回,如果不指定<timeout>,默认超时时间是5秒。

25. write <path> <string> [ <string> ]*

向<path>指定的文件写入一个或多个字符串。

Services (服务)是一个程序,他在初始化时启动,并在退出时重启(可选)。Services (服务)的形式如下:

    service <name> <pathname> [ <argument> ]*            <option>            <option>  

例如,下面是一个标准的Service用法

    service servicemanager /system/bin/servicemanager          class core          user system          group system          critical          onrestart restart zygote          onrestart restart media          onrestart restart surfaceflinger          onrestart restart drm  

Services的选项是服务的修饰符,可以影响服务如何以及怎样运行。服务支持的选项如下:

1. critical

表明这是一个非常重要的服务。如果该服务4分钟内退出大于4次,系统将会重启并进入 Recovery (恢复)模式。

2. disabled

表明这个服务不会同与他同trigger (触发器)下的服务自动启动。该服务必须被明确的按名启动。

3. setenv <name><value>

在进程启动时将环境变量<name>设置为<value>。

4. socket <name><type> <perm> [ <user> [ <group> ] ]

Create a unix domain socketnamed /dev/socket/<name> and pass

its fd to the launchedprocess. <type> must be"dgram", "stream" or "seqpacket".

User and group default to0.

创建一个unix域的名为/dev/socket/<name> 的套接字,并传递它的文件描述符给已启动的进程。<type> 必须是 "dgram","stream" 或"seqpacket"。用户和组默认是0。

5. user <username>

在启动这个服务前改变该服务的用户名。此时默认为 root。

6. group <groupname> [<groupname> ]*

在启动这个服务前改变该服务的组名。除了(必需的)第一个组名,附加的组名通常被用于设置进程的补充组(通过setgroups函数),档案默认是root。

7. oneshot

服务退出时不重启。

8. class <name>

指定一个服务类。所有同一类的服务可以同时启动和停止。如果不通过class选项指定一个类,则默认为"default"类服务。

9. onrestart

当服务重启,执行一个命令(下详)。

现在接着分析一下init是如何解析init.rc的。现在打开system/core/init/init.c文件,找到main函数。在上一篇文章中 分析了main函数的前一部分(初始化属性、处理内核命令行等),现在找到init_parse_config_file函数,调用代码如下:

init_parse_config_file("/init.rc");

这个方法主要负责初始化和分析init.rc文件。init_parse_config_file函数在init_parser.c文件中实现,代码如下:

    int init_parse_config_file(const char *fn)      {          char *data;          data = read_file(fn, 0);          if (!data) return -1;          /*  实际分析init.rc文件的代码  */          parse_config(fn, data);          DUMP();          return 0;      }  

init_parse_config_file方法开始调用了read_file函数打开了/init.rc文件,并返回了文件的内容(char*类 型),然后最核心的函数是parse_config。该函数也在init_parser.c文件中实现,代码如下:

    static void parse_config(const char *fn, char *s)      {          struct parse_state state;          struct listnode import_list;          struct listnode *node;          char *args[INIT_PARSER_MAXARGS];          int nargs;                nargs = 0;          state.filename = fn;          state.line = 0;          state.ptr = s;          state.nexttoken = 0;          state.parse_line = parse_line_no_op;                list_init(&import_list);          state.priv = &import_list;          /*  开始获取每一个token,然后分析这些token,每一个token就是有空格、字表符和回车符分隔的字符串        */          for (;;) {              /*  next_token函数相当于词法分析器  */              switch (next_token(&state)) {              case T_EOF:  /*  init.rc文件分析完毕  */                  state.parse_line(&state, 0, 0);                  goto parser_done;              case T_NEWLINE:  /*  分析每一行的命令  */                  /*  下面的代码相当于语法分析器  */                  state.line++;                  if (nargs) {                      int kw = lookup_keyword(args[0]);                      if (kw_is(kw, SECTION)) {                          state.parse_line(&state, 0, 0);                          parse_new_section(&state, kw, nargs, args);                      } else {                          state.parse_line(&state, nargs, args);                      }                      nargs = 0;                  }                  break;              case T_TEXT:  /*  处理每一个token  */                  if (nargs < INIT_PARSER_MAXARGS) {                      args[nargs++] = state.text;                  }                  break;              }          }            parser_done:          /*  最后处理由import导入的初始化文件  */          list_for_each(node, &import_list) {               struct import *import = node_to_item(node, struct import, list);               int ret;                     INFO("importing '%s'", import->filename);               /*  递归调用  */                ret = init_parse_config_file(import->filename);               if (ret)                   ERROR("could not import file '%s' from '%s'\n",                         import->filename, fn);          }      }  

parse_config方法的代码就比较复杂了,现在先说说该方法的基本处理流程。首先会调用 list_init(&import_list)初始化一个链表,该链表是用于存储通过import语句导入的初始化文件名。然后开始开始在 for循环中分析init.rc文件中的每一行代码。最后将init.rc文件分析完后,就会进入parser_done部分,并递归调用 init_parse_config_file方法分析通过import导入的初始化文件。

通过分析parse_config方法的原理,感觉也并不是很复杂。不过分析parse_config方法的具体代码,还需要点编译原理的知识(只是概念 上的就可以)。在for循环中调用了一个next_token方法不断从init.rc文件中获取token。这里的token,就是一种编程语言的最小 单元,也就是不可再分。例如,对于传统的编程语言,if、then等关键字、变量名等标识符都属于一个token。而对于init.rc文件来 说,import、on、以及触发器的参数值,都属于一个token。

一个完整的编译器(或解析器)最开始需要进行词法和语法分析,词法分析就是在源代码文件中挑出一个个的Token,也就是说,词法分析器的返回值是 Token,而语法分析器的输入就是词法分析器的输出。也就是说,语法分析器需要分析一个个的token,而不是一个个的字符。由于init解析语言很简 单,所以就将词法和语法分析器放到了一起。词法分析器就是next_token函数,而语法分析器就是T_NEWLINE分支中的代码。这些就清楚多了。 现在先看看next_token函数(在parser.c文件中实现)是如何获取每一个token的。

    int next_token(struct parse_state *state)      {          char *x = state->ptr;          char *s;                if (state->nexttoken) {              int t = state->nexttoken;              state->nexttoken = 0;              return t;          }          /*  在这里开始一个字符一个字符地分析  */          for (;;) {              switch (*x) {              case 0:                  state->ptr = x;                  return T_EOF;              case '\n':                  x++;                  state->ptr = x;                  return T_NEWLINE;              case ' ':              case '\t':              case '\r':                  x++;                  continue;              case '#':                  while (*x && (*x != '\n')) x++;                  if (*x == '\n') {                      state->ptr = x+1;                      return T_NEWLINE;                  } else {                      state->ptr = x;                      return T_EOF;                  }              default:                  goto text;              }          }            textdone:          state->ptr = x;          *s = 0;          return T_TEXT;      text:          state->text = s = x;      textresume:          for (;;) {              switch (*x) {              case 0:                  goto textdone;              case ' ':              case '\t':              case '\r':                  x++;                  goto textdone;              case '\n':                  state->nexttoken = T_NEWLINE;                  x++;                  goto textdone;              case '"':                  x++;                  for (;;) {                      switch (*x) {                      case 0:                              /* unterminated quoted thing */                          state->ptr = x;                          return T_EOF;                      case '"':                          x++;                          goto textresume;                      default:                          *s++ = *x++;                      }                  }                  break;              case '\\':                  x++;                  switch (*x) {                  case 0:                      goto textdone;                  case 'n':                      *s++ = '\n';                      break;                  case 'r':                      *s++ = '\r';                      break;                  case 't':                      *s++ = '\t';                      break;                  case '\\':                      *s++ = '\\';                      break;                  case '\r':                          /* \ <cr> <lf> -> line continuation */                      if (x[1] != '\n') {                          x++;                          continue;                      }                  case '\n':                          /* \ <lf> -> line continuation */                      state->line++;                      x++;                          /* eat any extra whitespace */                      while((*x == ' ') || (*x == '\t')) x++;                      continue;                  default:                          /* unknown escape -- just copy */                      *s++ = *x++;                  }                  continue;              default:                  *s++ = *x++;              }          }          return T_EOF;      }  

next_token函数的代码还是很多的,不过原理到很简单。就是逐一读取init.rc文件(还有import导入的初始化文件)的字符,并将 由空格、“/t”和“/r”分隔的字符串挑出来,并通过state->text返回。如果返回了正常的token,next_token函数就返回 T_TEXT。如果一行结束,就返回T_NEWLINE,如果init.rc文件的内容已读取完,就返回T_EOF。当返回T_NEWLINE时,开始语 法分析(由于init初始化语言是基于行的,所以语言分析实际上就是分析init.rc文件的每一行,只是这些行已经被分解成一个个token了)。感兴 趣的读者可以详细分析一下next_token函数的代码,尽管代码很多,但并不复杂。而且还很有意思。

现在回到parse_config函数,先看一下T_TEXT分支。该分支将获得的每一行的token都存储在args数组中。现在来看 T_NEWLINE分支。该分支的代码涉及到一个state.parse_line函数指针,该函数指针指向的函数负责具体的分析工作。但我们发现,一看 是该函数指针指向了一个空函数parse_line_no_op,实际上,一开始该函数指针什么都不做,只是为了使该函数一开始不至于为null,否则调 用出错。

现在来回顾一下T_NEWLINE分支的完整代码。

    case T_NEWLINE:          state.line++;          if (nargs) {              int kw = lookup_keyword(args[0]);              if (kw_is(kw, SECTION)) {                  state.parse_line(&state, 0, 0);                  parse_new_section(&state, kw, nargs, args);              } else {                  state.parse_line(&state, nargs, args);              }              nargs = 0;          }          break;  

在上面的代码中首先调用了lookup_keyword方法搜索关键字。该方法的作用是判断当前行是否合法,也就是根据Init初始化语言预定义的关键字 查询,如果未查到,返回K_UNKNOWN。lookup_keyword方法在init_parser.c文件中实现,代码如下:

    int lookup_keyword(const char *s)      {          switch (*s++) {          case 'c':          if (!strcmp(s, "opy")) return K_copy;              if (!strcmp(s, "apability")) return K_capability;              if (!strcmp(s, "hdir")) return K_chdir;              if (!strcmp(s, "hroot")) return K_chroot;              if (!strcmp(s, "lass")) return K_class;              if (!strcmp(s, "lass_start")) return K_class_start;              if (!strcmp(s, "lass_stop")) return K_class_stop;              if (!strcmp(s, "lass_reset")) return K_class_reset;              if (!strcmp(s, "onsole")) return K_console;              if (!strcmp(s, "hown")) return K_chown;              if (!strcmp(s, "hmod")) return K_chmod;              if (!strcmp(s, "ritical")) return K_critical;              break;          case 'd':              if (!strcmp(s, "isabled")) return K_disabled;              if (!strcmp(s, "omainname")) return K_domainname;              break;           … …          case 'o':              if (!strcmp(s, "n")) return K_on;              if (!strcmp(s, "neshot")) return K_oneshot;              if (!strcmp(s, "nrestart")) return K_onrestart;              break;          case 'r':              if (!strcmp(s, "estart")) return K_restart;              if (!strcmp(s, "estorecon")) return K_restorecon;              if (!strcmp(s, "mdir")) return K_rmdir;              if (!strcmp(s, "m")) return K_rm;              break;          case 's':              if (!strcmp(s, "eclabel")) return K_seclabel;              if (!strcmp(s, "ervice")) return K_service;              if (!strcmp(s, "etcon")) return K_setcon;              if (!strcmp(s, "etenforce")) return K_setenforce;              if (!strcmp(s, "etenv")) return K_setenv;              if (!strcmp(s, "etkey")) return K_setkey;              if (!strcmp(s, "etprop")) return K_setprop;              if (!strcmp(s, "etrlimit")) return K_setrlimit;              if (!strcmp(s, "etsebool")) return K_setsebool;              if (!strcmp(s, "ocket")) return K_socket;              if (!strcmp(s, "tart")) return K_start;              if (!strcmp(s, "top")) return K_stop;              if (!strcmp(s, "ymlink")) return K_symlink;              if (!strcmp(s, "ysclktz")) return K_sysclktz;              break;          case 't':              if (!strcmp(s, "rigger")) return K_trigger;              break;          case 'u':              if (!strcmp(s, "ser")) return K_user;              break;          case 'w':              if (!strcmp(s, "rite")) return K_write;              if (!strcmp(s, "ait")) return K_wait;              break;          }          return K_UNKNOWN;      }  

lookup_keyword方法按26个字母顺序(关键字首字母)进行处理。

现在回到parse_config方法的T_NEWLIEN分支,接下来调用了kw_is宏具体判断当前行是否合法,该宏以及SECTION宏的定义如下。根据这些代码。明显是keyword_info数组中的某个元素的flags成员变量的值取最后一位。

    #define SECTION 0x01      #define kw_is(kw, type) (keyword_info[kw].flags & (type))  

现在问题又转到keyword_info数组了。该数组也在init_parser.c文件中定义,代码如下:

    #include "keywords.h"      #define KEYWORD(symbol, flags, nargs, func) \          [ K_##symbol ] = { #symbol, func, nargs + 1, flags, },      struct {          const char *name;          int (*func)(int nargs, char **args);          unsigned char nargs;          unsigned char flags;      } keyword_info[KEYWORD_COUNT] = {          [ K_UNKNOWN ] = { "unknown", 0, 0, 0 },      #include "keywords.h"      };  

从表面上看,keyword_info数组是一个struct数组,但本质上,是一个map。为每一个数组元素设置了一个key,例如,数组元素{ "unknown", 0, 0,0 }的key是K_UNKNOWN,而#include “keywords.h”大有玄机。上面的代码中引用了两次keywords.h文件,现在可以看一下keywords.h文件的代码。

    #ifndef KEYWORD      int do_chroot(int nargs, char **args);      … …      int do_export(int nargs, char **args);      int do_hostname(int nargs, char **args);      int do_rmdir(int nargs, char **args);      int do_loglevel(int nargs, char **args);      int do_load_persist_props(int nargs, char **args);      int do_wait(int nargs, char **args);      #define __MAKE_KEYWORD_ENUM__      /*     "K_chdir", ENUM     */      #define KEYWORD(symbol, flags, nargs, func) K_##symbol,      enum {          K_UNKNOWN,      #endif          KEYWORD(capability,  OPTION,  0, 0)          KEYWORD(chdir,       COMMAND, 1, do_chdir)          KEYWORD(chroot,      COMMAND, 1, do_chroot)          KEYWORD(class,       OPTION,  0, 0)          KEYWORD(class_start, COMMAND, 1, do_class_start)          KEYWORD(class_stop,  COMMAND, 1, do_class_stop)          KEYWORD(class_reset, COMMAND, 1, do_class_reset)          KEYWORD(console,     OPTION,  0, 0)          … …          KEYWORD(critical,    OPTION,  0, 0)          KEYWORD(load_persist_props,    COMMAND, 0, do_load_persist_props)          KEYWORD(ioprio,      OPTION,  0, 0)      #ifdef __MAKE_KEYWORD_ENUM__          KEYWORD_COUNT,      };      #undef __MAKE_KEYWORD_ENUM__      #undef KEYWORD      #endif  

从keywords.h文件的代码可以看出,如果未定义KEYWORD宏,则在keywords.h文件中定义一个KEYWORD宏,以及一个枚举类型, 其中K_##symbol的##表示连接的意思。而这个KEYWORD宏只用了第一个参数(symbol)。例 如,KEYWORD(chdir, COMMAND, 1, do_chdir)就会生成K_chdir。

而在keyword_info结构体数组中再次导入keywords.h文件,这是KEYWORD宏已经在init_parser.c文件中重新定义,所以第一次导入keywords.h文件使用的是如下的宏。

    #define KEYWORD(symbol, flags, nargs, func) \          [ K_##symbol ] = { #symbol, func, nargs + 1, flags, },  

这下就明白了,如果不使用keywords.h文件,直接将所有的代码都写到init_parser.c文件中,就会有下面的代码。

    int do_chroot(int nargs, char **args);      … …      enum      {      K_UNKNOWN,      K_ capability,      K_ chdir,      … …      }      #define KEYWORD(symbol, flags, nargs, func) \          [ K_##symbol ] = { #symbol, func, nargs + 1, flags, },      struct {          const char *name;          int (*func)(int nargs, char **args);          unsigned char nargs;          unsigned char flags;      } keyword_info[KEYWORD_COUNT] = {          [ K_UNKNOWN ] = { "unknown", 0, 0, 0 },          [K_ capability] = {" capability ", 0, 1, OPTION },          [K_ chdir] = {"chdir", do_chdir ,2, COMMAND},          … …      #include "keywords.h"      };  

可能我们还记着lookup_keyword方法,该方法的返回值就是keyword_info数组的key。

在keywords.h前面定义的函数指针都是处理init.rc文件中service、action和command的。现在就剩下一个问题了,在哪里 为这些函数指针赋值呢,也就是说,具体处理每个部分的函数在哪里呢。现在回到前面的语法分析部分。如果当前行合法,则会执行 parse_new_section函数(在init_parser.c文件中实现),该函数将为section和action设置处理这两部分的函数。 parse_new_section函数的代码如下:

    void parse_new_section(struct parse_state *state, int kw,                             int nargs, char **args)      {          printf("[ %s %s ]\n", args[0],                 nargs > 1 ? args[1] : "");          switch(kw) {          case K_service:  //  处理service              state->context = parse_service(state, nargs, args);              if (state->context) {                  state->parse_line = parse_line_service;                  return;              }              break;          case K_on:  //  处理action              state->context = parse_action(state, nargs, args);              if (state->context) {                  state->parse_line = parse_line_action;                  return;              }              break;          case K_import:   //  单独处理import导入的初始化文件。              parse_import(state, nargs, args);              break;          }          state->parse_line = parse_line_no_op;      }  

现在看一下处理service的函数(parse_line_service)。

    static void parse_line_service(struct parse_state *state, int nargs, char **args)      {          struct service *svc = state->context;          struct command *cmd;          int i, kw, kw_nargs;                if (nargs == 0) {              return;          }                svc->ioprio_class = IoSchedClass_NONE;                kw = lookup_keyword(args[0]);          //  下面处理每一个option          switch (kw) {          case K_capability:              break;          … …          case K_group:              if (nargs < 2) {                  parse_error(state, "group option requires a group id\n");              } else if (nargs > NR_SVC_SUPP_GIDS + 2) {                  parse_error(state, "group option accepts at most %d supp. groups\n",                              NR_SVC_SUPP_GIDS);              } else {                  int n;                  svc->gid = decode_uid(args[1]);                  for (n = 2; n < nargs; n++) {                      svc->supp_gids[n-2] = decode_uid(args[n]);                  }                  svc->nr_supp_gids = n - 2;              }              break;          case K_keycodes:              if (nargs < 2) {                  parse_error(state, "keycodes option requires atleast one keycode\n");              } else {                  svc->keycodes = malloc((nargs - 1) * sizeof(svc->keycodes[0]));                  if (!svc->keycodes) {                      parse_error(state, "could not allocate keycodes\n");                  } else {                      svc->nkeycodes = nargs - 1;                      for (i = 1; i < nargs; i++) {                          svc->keycodes[i - 1] = atoi(args[i]);                      }                  }              }              break;              … …           }          ……      }  

Action的处理方式与service类似,读者可以自行查看相应的函数代码。现在一切都清楚了。处理service的函数是 parse_line_service,处理action的函数是parse_line_action。而前面的state.parse_line根据当 前是service还是action,指向这两个处理函数中的一个,并执行相应的函数处理actioncommand和serviceoption。

综合上述,实际上分析init.rc文件的过程就是通过一系列地处理,最终转换为通过parse_line_service或parse_line_action函数分析Init.rc文件中每一行的行为。

更多相关文章

  1. C语言函数以及函数的使用
  2. Android多媒体开发(5)————利用Android AudioTrack播放mp3文件
  3. android常用颜色代码定义
  4. AndroiManifest.xml文件中android属性
  5. Android 将从网络获取的数据缓存到私有文件
  6. android NDK JNI设置自己的log输出函数
  7. android删除sd卡文件
  8. Android实现文件夹目录选择器
  9. android之写文件到sd卡

随机推荐

  1. Android布局(layout)
  2. android onSaveInstanceState方法 横坚屏
  3. TP vue组合api-绑定事件\组件\通信\路
  4. 实例演示函数参数与返回值,模板字面量与模
  5. 路由和视图原理与实现
  6. Android弹性收缩自适应布局FlexboxLayout
  7. android 数据传输之JSON
  8. Android(安卓)图片的帧动画
  9. 关于Android(安卓)ListView组件中android
  10. Android的Message机制