Android应用程序开发以及背后的设计思想深度剖析 - Go语言中文社区

Android应用程序开发以及背后的设计思想深度剖析


原文链接:http://www.uml.org.cn/mobiledev/201211063.asp#2

初步过了一下,很多地方写得还是比较深入的,先转载,后面再仔细看看。

 

本文内容,主题是透过应用程序来分析Android系统的设计原理与构架。我们先会简单介绍一下Android里的应用程序编程,然后以这些应用程 序在运行环境上的需求来分析出,为什么我们的Android系统需要今天这样的设计方案,这样的设计会有怎样的意义, Android究竟是基于怎样的考虑才变成今天的这个样子,所以本文更多的分析Android应用程序设计背后的思想,品味良好架构设计的魅力。分五次连 载完成,第一部分是最简单的部分,解析Android应用程序的开发流程。

 

Android应用程序开发以及背后的设计思想深度剖析 1

1. Android应用程序

在目前Android大红大紫的情况下,很多人对编写Android应用程序已经有了足够深入的了解。即便是没有充分的认识,在现在Android 手机已经相当普及的情况下,大家至少也会知道Android的应用程序会是一个以.apk为后缀名的文件(在Windows系统里,还会是一个带可爱机器 人图标的文件)。那这个apk包又有什么样的含义呢?

如果您正在使用Linux操作系统,可以使用命令file命令来查看这一文件的类型。比如我们下载了一个Sample.apk的文件,则使用下面的命令:

 $file Sample.apk  
 Sample.apk: Zip archive data, at least v1.0 to extract  

对,没有看错,只一个简单的zip文件。要是做过Java开发的人,可以对这种格式很亲切,因为传说中的.jar、.war格式,都是Zip压缩格 式的文件。我们可继续使用unzip命令将这一文件解压(或是任何的解压工具,zip是人类历史是最会古老最为普及的压缩格式之一,几乎所有压缩工具都支 持)。通过解压,我们就得到了下面的文件内容:

AndroidManifest.xml,  
classes.dex,  
resources.arsc,  
META-INF,  
res, 

到这里,我们就可以看到一个Android应用程序结构其实是异常简单的。这五部分内容(其中META-INF和res是目录,其他是文件)除了 META-INF是这一.apk文件的校验信息,resources.arsc是资源的索引文件,其他三部分则构成了Android应用程序的全部。

  • AndroidManifest.xml,这是每个Android应用程序包的配置文件,这里会保存应用程序名字、作者、所实现的功能、以及一些权限验证信息。但很可惜,在编译完成的.apk文件里,这些文件都被编译成了二进制版本,我们暂时没有办法看到内容,后面我们可以再看看具体的内容。
  • classes.dex,这则是Android应用程序实现的逻辑部分,也就是通过Java编程写出来而被编译过的代码。这种特殊的格式,是Android里特定可执行格式,是可由Dalvik虚拟机所执行的代码,这部分内容我们也会在后续的介绍Dalvik虚拟机的章节里介绍。
  • res,这一目录里则保存了Android所有图形界面设计相关的内容,比如界面应该长成什么样子、支持哪些语言显示等等。

从一个android应用程序的包文件内容,我们可以看到android应用程序的特点,这也是Android编程上的一些特征:

1 简单:最终生成的结果是如些简单的三种组成,则他们的编程上也不会有太大的困难性。这并不是说 Android系统里无法实现很复杂的应用程序,事实上Android系统拥有世界上仅次于iOS的应用程序生态环境,也拥有复杂的办公软件、大型3D游 戏。而只是说,如果要实现和构成同样的逻辑,它必然会拥有其他格式混杂的系统更简化的编程模式。

2 Java操作系统:既然我们编译得到的结果,classes.dex文件,是用于Java虚拟机 (虽然是Dalvik虚拟机,但实际上这一虚拟机只是一种特定的Java解析器和虚拟机执行环境 )解析执行的,于是我们也可以猜想到,我们的Android系统,必然是一个Java操作系统。我们在后面会解释,如果把Android系统直接看成 Linux内核和Java语言组合到一起的操作系统很不准确,但事实上Android,也还是Java操作系统,Java是唯一的系统入口。

使用MVC设计模式:所谓的MVC,就是Model,View,Controller的首字母组合起来的一种设计模 式,主要思想就是把显示与逻辑实现分离。Model用于保存上下文状态、View用于显示、而Controller则是用于处理用户交互。三者之间有着如 下图所示的交互模型,交互只到Controller,而显示更新只通过View进行,这两者再与Model交换界面状态信息:

在现代的图形交互相关的设计里,MVC几乎是在图形交互处理上的不二选择,这样系统设计包括一些J2EE的应用服务器框架,最受欢迎的 Firefox浏览器,iOS,MacOSX等等。这些使用MVC模式的最显著特点就是显示与逻辑分离,在Android应用程序里我们看到了用于逻辑实 现的classes.dex,也看到用于显示的res,于是我们也可以猜想到在UI上便肯定会使用MVC设计模式。

当然,所谓的Android应用程序编程,不会只有这些内容。到目前为止,我们也只是分析.apk文件,于是我们可以回过头来看看Android应用被编译出来的过程。

2. Android编程

从编程角度来说,Android应用程序编程几乎只与Java相关,而Java平台本身是出了名跨平台利器,理论上来说,所有Java环境里使用的 编程工具、IDE工具,皆可用于Android的编程。Android SDK环境里提供的编程工具,是基于标准的Java编译工具ant的,但事实上,一些大型的Android软件工程,更倾向于使用Maven这样的并行化 编译工具(maven.apache.org)。如果以前有过Java编程经验,会知道Java环境里的图形化IDE(Integrated Development Environment)工具,并非只有Eclipse一种,实际上Java的官方IDE是NetBeans,而商用化的Java大型项目开发者,也可能 会比较钟意于使用IntelliJ,而从底层开发角度来说,可能使用vim是更合适的选择,可以灵活地在C/C++与Java代码之间进行切换。总而言 之,几乎所有的Java环境的编程工具都可以用于Android编程。

对于这些工具呢,熟悉工具的使用是件好事,所谓“磨刀不误砍柴工”,为将来提升效率,这是件好事。但是要磨刀过多,柴没砍着,转型成“磨刀工”了。如果过多地在这些编程工具上纠结尝试,反而忽视了所编代码的本身,这倒会舍本逐末。

我们既然是研究Android编程,这时仅说明两种Android官方提供的编程方法:使用Android SDK工具包编程,或是使用Eclipse + ADT插件编程。

2.1 使用Android SDK工具包

在Android开发过程中,如果Eclipse环境不可得的情况下,可以直接使用SDK来创建应用程序工程。首先需要安装某一个版本的Android SDK开发包,这个工具包可以到http://developer.android.com/sdk/index.html这 个网址去下载,根据开发所用的主机是Windows、Linux还是MacOS X(MacOS仅支持Intel芯片,不支持之前的PowerPC芯片),下载对应的.zip文件,比如android-sdk_r19- linux.zip。下载完成后,解压到一个固定的目录,我们这里假定是通过环境变量$ANDROID_SDK_PATH指定的目录。

下载的SDK包,默认是没有Android开发环境支持的,需要通过tools目录里的一个android工具来下载相应的SDK版本以用于开发。我们通过运行$ANDROID_SDK_PATH/tools/android会得到如下的界面:

在上面的安装界面里选择不同的开发工具包,其中Tools里包含一些开发用的工具,如我们的SDK包,实际上也会在这一界面里进行更新。而对于不同 的Android版本,1.5到4.1,我们必须选择下载某个SDK版本来进行开发。而下载完之后的版本信息,我们既可以在这一图形界面里看到,也可以通 过命令行来查看。

$ANDROID_SDK_PATH/tools/android list targets  
id: 1 or "android-16"  
     Name: Android 4.1  
     Type: Platform  
     API level: 16  
     Revision: 1  
     Skins: HVGA, QVGA, WQVGA400, WQVGA432, WSVGA, WVGA800 (default), WVGA854, WXGA720, WXGA800, WXGA800-7in  
     ABIs : armeabi-v7a  
----------  
id: 2 or "Google Inc.:Google APIs:16"  
     Name: Google APIs  
     Type: Add-On  
     Vendor: Google Inc.  
     Revision: 1  
     Description: Android + Google APIs  
     Based on Android 4.1 (API level 16)  
     Libraries:  
      * com.google.android.media.effects (effects.jar)  
          Collection of video effects  
      * com.android.future.usb.accessory (usb.jar)  
          API for USB Accessories  
      * com.google.android.maps (maps.jar)  
          API for Google Maps  
     Skins: WVGA854, WQVGA400, WSVGA, WXGA800-7in, WXGA720, HVGA, WQVGA432, WVGA800 (default), QVGA, WXGA800  
     ABIs : armeabi-v7a  

通过android list targets列出来的信息,可以用于后续的开发之用,比如对于不同的target,最后得到了id:1、id:2这样的信息,则可以被用于应用程序工程 的创建。而细心一点的读者会看到同一个4.1版本的SDK,实际可分为”android-16”和"Google Inc.:Google APIs:16",这样的分界也还有有意义的,”android-16”用于“纯”的android 4.1版的应用程序开发,而“Google Inc.:Google APIs:16”则加入了Google的开发包。
配置好环境之后,如果我们需要创建Android应用程序。tools/android这个工具,同时也具备可以创建Android应用程序工程的能力。我们输入:

$ANDROID_SDK_PATH/tools/android create project -n Hello -t 1 -k org.lianlab.hello -a Helloworld -p hello  

这样我们就在hello目录里创建了一个Android的应用程序,名字是Hello,使用API16(Android 4.1的API版本),包名是org.lianlab.hello,而默认会被执行到的Activity,会是叫Helloworld的Activity 类。

掌握Android工具的一些使用方法也是有意义的,比如当我们的Eclipse工程被破坏的情况下,我们依然可以手工修复这一Android应用程序工程。或是需要修改该工程的API版本的话,可以使用下面的命令:

$ANDROID_SDK_PATH/tools/android updateproject -t 2 -p .

在这个工程里,如果我们不加任何修改,会生成一个应用程序,这个应用程序运行的效果是生成一个黑色的图形界面,打印出一行"Hello World, Helloworld"。如果我们需要对这一工程进行编译等操作的话,剩下的事情就属于标准的Java编译了,标准的Java编译,使用的是 ant(ant.apache.org)编译工具。我们先改变当前目录到hello,然后就可以通过” ant –projecthelp”来查看可以被执行的Android编译工程,

$ ant -projecthelp  
Buildfile: /Users/wuhe/android/workspace/NotePad/bin/tmp/hello/build.xml
  
Main targets:  
  
 clean       Removes output files created by other targets.  
 debug       Builds the application and signs it with a debug key.  
 install     Installs the newly build package. Must be used in conjunction with a build target 
             (debug/release/instrument). If the application was previously installed, the application 
             is reinstalled if the signature matches.
 installd    Installs (only) the debug package.  
 installi    Installs (only) the instrumented package. 
 installr    Installs (only) the release package.  
 installt    Installs (only) the test and tested packages.  
 instrument  Builds an instrumented packaged.  
 release     Builds the application in release mode.  
 test        Runs tests from the package defined in test.package property 
 uninstall   Uninstalls the application from a running emulator or device.
Default target: help  

但如果只是编译,我们可以使用antdebug生成Debug的.apk文件,这时生成的文件,会被放到bin/Hello-debug.apk。 此时生成的Hello-debug.apk,已经直接可以安装到Android设备上进行测试运行。我们也可以使用ant release来生成一个bin/Hello-release-unsigned.apk,而这时的.apk文件,则需要通过jarsigner对文件进 行验证才能进行安装。

通过antdebug这一编译脚本,我们可以看到详细的编译过程。我们可以看到,一个Android的工程,最后会是通过如图所示的方式生成最后的.apk文件。

把一个Android的源代码工程编译成.apk的Android应用程序,其过程如下:

1) 所有的资源文件,都会被aapt进行处理。所有的XML文件,都会被aapt解析成二进制格式,准确地说,这样的二进制格式,是可以被直接映射到内存里的 二进制树。做过XML相关开发的工程师,都会知道,XML的验证与解析是非常消耗时间与内存的,而通过编译时进行XML解析,则节省了运行时的开销。当然 解析的结果最后会被aapt通过一个R.java保存一个二进制树的索引,编程时可通过这个R.java文件进行XML的访问。aapt会处理所有的资源 文件,也就是Java代码之外的任何静态性文件,这样处理既保证了资源文件间的互相索引得到了验证,也确保了R.java可以索引到这个应用程序里所有的 资源。

2) 所有的Java文件,都会被JDK里的javac工具编译成bin目录下按源代码包结构组织的.class文件(.class是标准的Java可解析执行 的格式),比如我们这个例子里生成的bin/classes/org/lianlab/hello/*.class文件。然后这些文件,会通过SDK里提 供的一个dx工具转换成classes.dex文件。这一文件,就是会被Dalvik虚拟机所解析执行的

3) 最后我们得到的编译过的二进制资源文件和classes.dex可执行文件,会通过一个apkbuilder工具,通过zip压缩算法打包到一个文件里,生成了我们所常见的.apk文件。

4) 最后,.apk文件,会通过jarsigner工具进行校验,这一校验值会需要一个数字签名。如果我们申请了Android开发者帐号,这一数字签名就是 Android所分发的那个数字证书;如果没有,我们则使用debug模式,使用本地生成的一个随机的数字证书,这一文件位于~/.android /debug.keystore。

虽然我们只是下载了SDK,通过一行脚本创建了Android应用程序工程,通过另一行完成了编译。但也许还是会被认为过于麻烦,因为需要进行字符界面的 操作,而且这种开发方式也不是常用的方式,在Java环境下,我们有Eclipse可用。我们可以使用Eclipse的图形化开发工具,配合ADT插件使 用。

2.2 使用Eclipse+ADT插件

在Android环境里可以使用Java世界里几乎一切的开发工具,比如NetBeans等,但Eclipse是Android官方标准的开发方 式。使用Eclipse开发,前面提到的开发所需SDK版本下载,也是必须的,然后还需要在Eclipse环境里加装ADT插件,Android Development Toolkit。

我们在Eclipse的菜单里,选择”Help” à “Install New Software…”,然后在弹出的对话框里的Workwith:输入ADT的发布地址:https://dl-ssl.google.com/android.eclipse,回车,则会得到下面的软件列表。选择Select All,将这些插件全都装上,则得到了可用于Android应用程序开发的环境。

这里还需要指定SDK的地址,Windows或是Linux里,会是在菜单“Window” à “Preferences”,在MacOS里,则会是”Eclipse” à“Preferences” 。在弹出的对话框里,选择Android,然后填入Android SDK所保存的位置。

点击OK之后,则可以进行Android开发了。选择”File” à “New”à “Project” à “Android”,在Eclipse 3.x版本里,会是“Android Project”,在Eclipse 4.x版本里,会是“Android Application Project”。如果我们需要创建跟前面字符界面下一模一样的应用程序工程,则在弹出的创建应用程序对话框里填入如下的内容:

然后我们选择Next,一直到弹出最后界面提示,让我们选择默认Activity的名字,最后点击”Finish”,我们就得到一个Android 应用程序工程,同时在Eclipse环境里,我们既可以通过图形化界面编辑Java代码,也可以通过图形化的界面编辑工具来绘制图形界面。

(注意: 如果Android工程本身比较庞大,则最好将Eclipse里的内存相关的配置改大。在Windows和Linux里,是修改eclipse里的 eclipse.ini文件,而在MacOS里,则是修改Eclipse.app/Contents/MacOS/eclipse.ini。一般会将如下 的值加大成两倍:

--launcher.XXMaxPermSize  
512m  
-vmargs  
-Xms80m  
-Xmx1024m  
)  

我们得到工程目录,在Eclipse环境里会是如下图所示的组织方式。代码虽然是使用一模一样的编译方式,唯一的改变是,我们不再需要使用脚本来完 成编译,我们可以直接使用Eclipse的”Project”à“Build project”来完成编译过程。如果我们使用默认设置,则代码是使用自动编译的,我们的每次修改都会触发增量式的编译。

我们从这些android编程的过程,看不出来android跟别的Java编程模式有什么区别,倒至少验证了我们前面对android编程特点的 猜想,就是很简单。如果同样我们使用Eclipse开发Java的图形界面程序,需要大量地时间去熟悉API,而在Android这里学习的曲线被大大降 低,如果我们只是要画几个界面,建立起简单的交互,我们几乎无须学习编程。

而从上面的步骤,我们大概也可以得到Android开发的另一个好处,就是极大的跨平台性,它的开发流程里除了JDK没有提及任何的第三方环境需 求,于是这样的开发环境,肯定可以在各种不同的平台执行。这也是Android上进行开发的好处之一,跨平台,支持Windows,Linux与 MacOS三种。

我们再来看一个,我们刚才创建的这个工程里,我们怎么样进行下一步的开发。在Android开发里,决定我们应用程序表现的,也就是我们从一个.apk文件里看到的,我们实际上只需要:

  • 修改AndroidManifest.xml文件。AndroidManifest.xml是Android应用程序的主控文件,类型于Windows里的注册表,我们通过它来配置我们的应用程序与系统相关的一些属性。
  • 修改UI显示。在Android世界里,我们可以把UI编程与Java编程分开对待,处理UI控件 的语言,我们可以叫它UI语言,或是layout语言,因为它们总是以layout类型的资源文件作为主入口的。Android编程里严格地贯彻MVC的 设计思路,使我们得到了一个好处,就是我们的UI跟要实现的逻辑没有任何必然联系,我们可先去调整好UI显示,而UI显示后台的实现逻辑,则可以在后续的 步骤里完成。
  • 改写处理逻辑的Java代码。也就是我们MVC里的Controller与Model部分,这些部 分的内容,如果与UI没有直接交互,则我们可以放心大胆的改写,存在交互的部分,比如处理按钮的点击,取回输入框里的文字等。作为一个定位于拓展能力要求 最高的智能手机操作系统,android肯定不会只实现画画界面而已,会有强大的可开发能力,在android系统里,我们可以开发企业级应用,大型游 戏,以及完整的Office应用。

无论是通过tools/android工具生成的Android源代码目录,还是通过Eclipse来生成的Android源代码工程,都需要进一 步去自定义这个步骤来完成一个Android应用程序。当然,还有一种特殊的情况就是,这个源代码工程并非直接是一个Android应用程序,只是 Unit Test工程或是库文件工作,并不直接使用.apk文件,这里则可能后续的编程工作会变得不同。我们这里是分析Android应用程序,于是后面分别来看 应用程序编程里的这三部分的工作如何进行。

2.3 AndroidManifest.xml

先来看看AndroidManifest.xml文件,一般出于方便,这一文件有可能也被称为manifest文件。像我们前面的例子里的创建的Android工程,得到的AndroidManifest.xml文件就很简单:

 <?xml version="1.0" encoding="utf-8"?>  
<manifest xmlns:android="http://schemas.android.com/apk/res/android"  
      package="org.lianlab.hello"  
      android:versionCode="1"  
      android:versionName="1.0">  
    <application android:label="@string/app_name">  
        <activity android:name=".Helloworld"  
                  android:label="@string/app_name">  
            <intent-filter>  
                <action android:name="android.intent.action.MAIN" />  
                <category android:name="android.intent.category.LAUNCHER" />  
            </intent-filter>  
        </activity>  
    </application>  
</manifest>  

<div id="chart" style="width:360px;height:200px"/>
</body>
</html> 

作为xml文件,所有的<>都会是成对的,比如我们看到的<manifest></manifest>,这被 称为标签(Tag)。标签可以包含子标签,从而可以形成树型的结点关系。如果没有子标签,则我们也可以使用</>来进行标识,比如我们上面看 到的<actionandroid:name=”android.intent.action.MAIN” />。

<manifest>,是主标签,每个文件只会有一个,这是定义该应用程序属性的主入口,包含应用程序的一切信息,比如我们的例子里定义了xml的命名空间,这个应用程序的包名,以及版本信息。

<application>,则是用于定义应用程序属性的标签,理论上可以有多个,但多个不具有意义,一般我们一个应用程序只会有一个,在这个标签里我们可以定义图标,应用程序显示出来的名字等。在这一标签里定义的属性一般也只是辅助性的。

<activity>,这是用来定义界面交互的信息。我们在稍后一点的内容介绍Android编程细节时会描述到这些信息,这一标签里的属性定义会决定应用程序可显示效果。比如在启动界面里的显示出来的名字,使用什么样的图标等。

<intent-filter>,这一标签则用来控制应用程序的能力的,比如该图形界面可以完成什么样的功能。我们这里的处理比较简单,我们只是能够让这个应用程序的HelloWorld可以被支持点击到执行。

从这个最简单的AndroidManifest.xml文件里,我们可以看到Android执行的另一个特点,就是可配置性强。它跟别的编程模型很不一样的地方是,它没有编程式规定的main()函数或是方法,而应用程序的表现出来的形态,完全取决于<activity>字段是如何定义它的。

2.4 图形界面(res/layout/main.xml)

我们可以再来看android的UI构成。UI也是基于XML的,是通过一种layout的资源引入到系统里的。在我们前面看到的最简单的例子里,我们会得到的图形界面是res/layout/main.xml,在这一文件里,我们会看到打开显示,并在显示区域里打印出Hello World, Helloworld。

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >
    <TextView
        android:id="@+id/textView1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:padding="@dimen/padding_medium"
        android:text="@string/hello_world"
        tools:context=".HelloWorld" />
</RelativeLayout> 

在这个图形界面的示例里,我们可以看到,这样的图形编程方式,比如传统的方式里要学习大量的API要方便得多。

<LinearLayout>,这一标签会决定应用程序如何在界面里摆放相应的控件

<TextView>,则是用于显示字符串的图形控件

使用这种XML构成的UI界面,是MVC设计的附属产品,但更大的好处是,有了标准化的XML结构,就可以创建可以用来画界面的IDE工具。一流的系统提供工具,让设计师来设计界面、工程师来逻辑,这样生产出来的软件产品显示效果与用户体验会更佳,比如iOS;二流的系统,界面与逻辑都由工程师来完成,在这种系统上开发出来的软件,不光界面不好看,用户体验也会不好。我们比如在Eclipse里的工程里查看,我们会发现,我们打开res/layout/main.xml,会自动弹出来下面的窗口,让我们有机会使图形工具来操作界面。

在上面IDE工具里,左边是控件列表,中间是进行绘制的工作区,右边会是控件一些微调窗口。一般我们可以从左边控制列表里选择合适的控件,拖到中间的工作区来组织界面,原则上的顺序是layout à 复合控件 à 简单控件。中间区域可以上面还有选择项用于控制显示属性,在工作区域里我们可以进一步对界面进行微调,也可以选择控件点击左键,于是会出来上下文菜单来操作控件的属性。到于右边的操作界面,上部分则是整个界面构成的树形结构,而下部分则是当我们选择了某个界面元素时,会显示上下文的属性。最后,我们还可以在底部的Graphic Layout与main.xml进行图形操作界面与源代码编辑两种操作方式的切换。

有了这种工具,就有可能实现设计师与工程师合作来构建出美观与交互性更好的Android应用程序。但可惜的是,Android的这套UI设计工具,太过于编程化,而且由于版本变动频繁的原因,非常复杂化,一般设计师可能也不太容易学好。更重要的一点,Android存在碎片化,屏幕尺寸与显示精度差异性非常大,使实现像素级精度的界面有技术上的困难。也这是Android上应用程序不如iOS上漂亮的原因之一。但这种设计至少也增强了界面上的可设计性,使Android应用程序在观感上也有不俗表现。

我们可以再回过头来看看应用程序工程里的res目录,res目录里包含了Android应用程序里的可使用的资源,而资源文件本身是可以索引的,比如layout会引用drawable与values里的资源。对于我们例子里使用的<TextView … android:text="Hello World,Helloworld" …>,我们可以使用资源来进行引用,<TextView …android:text=” @string/hello_string” …>,然后在res/values/strings.xml里加入hello_string的定义。

.<string name="hello_world">Hello world!</string>

从通过这种方式,我们可以看另外一些特点,就是Android应用程序在多界面、多环境下的自适应性。对于上面的字符串修改的例子,我们如果像下面的示例环境那样定义了res/layout-zh/strings.xml,并提供hello_string的定义:

<string name="hello_world">欢迎使用!</string>

最后,得到的应用程序,在英文环境里会显示‘Hello world!’,而如果系统当前的语言环境是中文的话,就会显示成‘欢迎使用!’。这种自适应方式,则是不需要我们进行编程的,系统会自动完成对这些显示属性的适配。

当然,这时可能会有人提疑问,如果这时是韩文或是日文环境会出现什么情况呢?在Android里,如果不能完成相应的适配,就会使用默认值,比如即使是我们创建了res/values-zh/strings.xml资源,在资源没有定义我们需要使用的字符串,这时会使用英文显示。不管如何,Android提供自适应显示效果,但也保证总是不是会出错。

这些也体现出,一旦Android应用程序写出来,如果对多语言环境不满意,这时,我们完全可以把.apk按zip格式解开,然后加入新的资源文件定义,再把文件重新打包,也就达到了我们可能会需要的汉化的效果。

在res目录里,我们看到,对于同一种类型的资源,比如drawable、values,都可以在后面加一个后缀,像mdpi,hdpi, ldpi是用于适配分辨率的,zh是用来适配语言环境的,large则是用来适配屏幕大小的。对于这种显示上的自适应需求,我们可以直接在Eclipse里通过创建Android XML文件里得到相应的提示,也可以参考http://developer.android.com/training/basics/supporting-devices/查看具体的使用方式。

于是,透过资源文件,我们进一步验证了我们对于Android MVC的猜想,在Android应用程序设计里,也跟iOS类似,可以实现界面与逻辑完全分离。而另一点,就是Android应用程序天然具备屏幕自适应的能力,这一方面带来的影响是Android应用程序天生具备很强的适应性,另一方面的影响是Android里实现像素精度显示的应用程序是比较困难的,维护的代价很高。

我们可以再通过应用程序的代码部分来看看应用程序是如何将显示与逻辑进行绑定的。

2.5 Java编程(src/org/lianlab/hello/HelloWorld.java)

在Android编程里,实现应用程序的执行逻辑,几乎就是纯粹的Java编程。但在编程上,由于Android的特殊性,这种Java编程也还是被定制过的

我们看到我们例子里的源代码,如果写过Java代码,看到这样的源代码存放方式,就可以了解到Android为什么被称为Java操作系统的原因了,像这种方式,就是标准的Java编程了。事实上,在Android的代码被转义成Dalvik代码之前,Android编程都可被看成标准的Java编程。我们来看这个HelloWorld.java的源代码。

package org.lianlab.hello;
import android.os.Bundle;
import android.app.Activity;

public class HelloWorld extends Activity {
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
    }
}

代码结构很简单,我们所谓的HelloWorld,就是继承了Activity的基类,然后再覆盖了Acitivity基于的onCreate()方法。

Activity类,是Android系统设计思路里的很重要的一部分,所有与界面交互相关的操作类都是Activity,是MVC框架里的Controller部分。那Model部分由谁来提供呢?这是由Android系统层,也就是Framework来提供的功能。当界面失去焦点时,当界面完全变得不可见时,这些都属于Framework层才会知道的状态,Framework会记录下这些状态变更的信息,然后再回调到Activity类提供的相应状态的回调方法。关于Activity我们后面来详细说明,而见到Activity类的最简单构成,我们大体上就可以形成Android世界里的完整MVC框架构成完整印象了。

我们继承了Activity类之后,就会覆盖其onCreate()回调方法。这里我们使用了”@Override”标识,这是一种Java语言里的Annotation(代码注释)技术,相当于C语言里的pragma,用于告诉编译器一些相应参数。我们的Override则告诉javac编译器,下面的方法在构建对象时会覆盖掉父类方法,从而提高构建效率。Activity类里提供的onXXX()系列的都可以使用这种方法进行覆盖,从而来实现自定义的方法,切入到Android应用程序不同状态下的自定义实现。

我们覆盖掉的onCreate()方法,使用了一个参数,savedInstanceState,这个参数的类型是Bundle。Bundle是构建在Android的Binder IPC之上的一种特殊数据结构,用于实现普通Java代码里的Serialization/Deserializaiton功能,序列化与反序列化功能。在Java代码里,我们如果需要保存一些应用程序的上下文,如果是字符串或是数据值等原始类型,则可以直接写到文件里,下次执行时再把它读出来就可以了。但假设我们需要保存的是一个对象,比如是界面的某个状态点,像下面的这样的数据结构:

class ViewState {
    public int focusViewID;
    public Long layoutParams ;
    public String textEdited;
…
}

这时,我们就无法存取这样的结构了,因为这样的对象只是内存里的一些标识,存进时是一个进程上下文环境,取回来时会是另一种,就会出错。为了实现这样的功能,就需要序列化与反序列化,我们读写时都不再是以对象为单位,而是以类似于如下结构的一种字典类型的结构,最后进行操作的是一个个的键值对, ViewState[‘focusViewID’]的值会是valueOfViewID,一个整形值。

‘ViewState’ {
            ‘focusViewID’: valueOfViewID,
            ‘LayoutParams’:valueOfLayoutParams,
            ‘textEdited’:  ‘User input’,

}

我们按这种类似的格式写到文件里,当再读取出来时,我们就可以新建一个ViewState对象,再使用这些保存过的值对这一对象进行初始化。这样就可以实现对象的保存与恢复,这是我们onCreate()方法里使用Bundle做序列化操作的主要目的,我们的Activity会有不同生存周期,当我们有可能需要在进程退出后再次恢复现象时,我们就会在退出前将上下文环境保存到一个onSavedInstance的Bundle对象里,而在onCreate()将显示的上下文恢复成退出时的状态。

而另一个必须要使用Bundle的理由是,我们的Activity与实现Activity管理的Framework功能部件ActivityManager,是构建在不同进程空间上的,Activity将运行在自己独立的进程空间里,而Framework则是运行在另一个系统级进程SystemServer之上。我们的Bundle是一种进行过序列化操作的对象,于是相应的操作是系统进程会触发Activity的进行onCreate()回调操作,而同时会转回一个上下文环境的Bundle,可将Activity恢复到系统指定的某种图形界面状态。Bundle也可能为空,比如Activity是第一个被启动的情况下,这个空的onSavedInstance则会被忽略掉。

我们进入到onCreate()方法之后,第一行便是

super.onCreate(savedInstanceState);

从字面上看,这种方式相当于我们继承了父类方法,然后又回调到父类的onCreate()来进行处理。这种方式貌似很怪,但这是设计模式(Design Pattern)里鼎鼎大名的一种,叫IoC ( Inversion of Control)。通过这样的设计模式,我们可以同时提供可维护性与可调试性,我们可以在通过覆盖的方法提供功能更丰富的子类,实际上每次调用子类的onCreate()方法,都将调用到各个Activity拓展类的onCreate()方法。而这个方法一旦进入,又会回调到父类的onCreate()方法,在父类的onCreate()方法里,我们可以提供更多针对诸多子类的通用功能(比如启动时显示的上下文状态的恢复,关闭时一些清理性工作),以及在这里面插入调试代码。

然后,我们可以加载显示部分的代码的UI,

setContentView(R.layout.main);

这一行,就会使我们想要显示的图形界面被输出到屏幕上。我们可以随意地修改我们的main.xml文件,从而使setContentView()之后显示出来的内容随之发生变化。当然,作为XML的UI,最终是在内存里构成的树形结构,我们也可以在调用setContentView()之前通过编程来修改这个树形结构,于是也可以改变显示效果。

到目前为止,我们也只是实现了将内容显示到屏幕上,而没有实现交互的功能。如果要实现交互的功能,我们也只需要很简单的代码就可以做到,我们可以将HelloWorld.java改成如下的内容,从而使用我们的”Hello world”字符串可以响应点击事件:

package org.lianlab.hello;

import android.os.Bundle;
import android.app.Activity;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.TextView;

public class HelloWorld extends Activity {
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
      ((TextView)findViewById(R.id.textView1)).setOnClickListener(
                new OnClickListener() {
                   @Override
                   public void onClick(View v) {
                           finish();
                   }
           });
    }
}

我们使用Activity类的findViewById()方法,则可以找到任何被R.java所索引起来的资源定义。我们在这里使用了R.id.textView1作为参数,是因为我们在main.xml就是这么定义TextView标签的:android:id="@+id/textView1"。

而我们找到字段之后,会调用TextView对象的setOnClickListener()方法,给TextView注册一个onClickListener对象。这样的对象,是我们在Android世界里遇到的第二次设计模式的使用(事实上Android的实现几乎使用到所有的Java世界里的通用设计模式),Listener本身也会被作为Observer设计模式的一种别称,主要是用于实现被动调用逻辑,比如事件回馈。

Observer(Listener)设计模式的思路,跟我们数据库里使用到的Trigger功能类似,我们可对需要跟踪的数据操作设置一个Trigger,当这类数据操作进行时,就会触发数据库自动地执行某些操作代码。而Observer(Listener)模式也是类似的,监听端通过注册Observer来处理事件的回调,而真正的事件触发者则是Observer,它的工作就是循环监听事件,然后再调用相应监听端的回调。
这样的设计,跟效率没有必然联系,太可以更大程度地降低设计上的复杂度,同时提高设计的灵活性。一般Observer作为接口类,被监听则会定位成具体的Subject,真正的事件处理,则是通过实现某个Observer接口来实现的。对于固定的事件,Subject对象与Observer接口是无须变动的,而Observer的具体实现则可以很灵活地被改变与扩充。如下图所示:

如果我们对于监听事件部分的处理,也希望能加入这样的灵活性,于是我们可以继续抽象,将Subject泛化成一个Observable接口,然后可以再提供不同的Observable接口的实现来设计相应的事件触发端。

针对于我们的Android里的OnClickListener对象,则是什么情况呢?其实不光是OnClickListener,在Android里所有的事件回调,都有类似于Observer的设计技巧,这样的回调有OnLongClickListener,OnTouchListener,OnKeyListener,OnContextMenuListener,以及OnSetOnFocusChangeListener等。但Android在使用设计模式时很简洁,并不过大地提供灵活性,这样可以保证性能,也可以减小出错的概率(基本上所有的设计复杂到难以理解的系统,可维护性远比简单易懂但设计粗糙的系统更差,因为大部分情况下人的智商也是有限的资源)。于是,从OnClickLister的角度,我们可以得到下图所示的对象结构。

Click事件的触发源是Touch事件,而当前View的Touch事件在属于点击事件的情况下,会生成一个performClick的Runnable对象(可交由Thread对象来运行其run()回调方法)。在这个Runnable对象的run()方法里会调用注册过的OnClickListener对象的OnClick()方法,也就是图示中的mOnClickListener::onClick()。当这个对象被post()操作发送到主线程时(作为Message发送给UI线程的Hander进行处理),我们覆盖过的OnClick()回调方法就由主线程执行到了。

我们注册的Click处理,只有简单的一行,finish(),也就是通过点击事件,我们会将当前的Activity关闭掉。如果我们觉得这样不过瘾,我们也可通过这次点击触发另一个界面的执行,比如直接搜索这个字符串。这样的改动代码量很小,首先,我们需要在HelloWorld.java的头部引入所需要的Java包,

import android.app.SearchManager;
import android.content.Intent;

然后可以将我们的OnClick()方法改写成启动一个搜索的网页界面,查找这个字符串,而当前界面的Activity则退出。这时,我们新的OnClick()方法则会变成这个样子:

 public void onClick(View v) {
        Intent query = new Intent(Intent.ACTION_WEB_SEARCH);
        query.putExtra(SearchManager.QUERY,
 ((TextView)v).getText());
        startActivity(query);
        finish();
   }            

但是可能还是无法解决我们对于Android应用程序与Java环境的区别的疑问:

  • Android有所谓的MVC,将代码与显示处理分享,但这并非是标准Java虚拟机环境做不到。一些J2EE的软件框架也有类似的特征
  • AndroidManifest.xml与On*系列回调,这样的机制在JAVA ME也有,JAVA ME也是使用类似的机制来运行的,难道Android是JAVA ME的加强版?
  • 至于Listener模式的使用,众所周知,Java是几乎所有高级设计模式的实验田,早就在使用Listener这样模式在处理输入处理。唯一不同的是ClickListener,难道Android也像是可爱的触摸版Ubuntu手机一样,只在是桌面Java界面的基础加入了触摸支持?
  • Activity从目前的使用上看,不就是窗口(Window)吗?Android开发者本就有喜欢取些古怪名字的嗜好,是不是他们只是标新立异地取了个Activity的名字?

对于类似这样的疑问,则是从代码层面看不清楚了,我们得回归到Android的设计思想这一层面来分析,Android应用程序的执行环境是如何与众不同的。

不过,我们可以从最后的那行Activity调用另一个Activity的例子里看出一些端倪,在这次调用里,我们并没有显式地创建新的Activity,如果从代码直接去猜含义的话,我们只是发出了个执行某种操作的请求,而这个请求并没有指定有谁来完成。这就是Android编程思想的基础,一种全开放的“无界化”编程模型。

Android应用程序开发以及背后的设计思想深度剖析(2)

Android的系统设计,与别的智能手机操作系统有很大区别,甚至在以往的任何操作系统里,很难找到像Android这样进行全面地系统级创新的操作系统。从创新层面上来说,Android编程上的思想和支持这种应用程序运行环境的系统,这种理念本身就是一种大胆的创新。

整个Android系统,实际主要目的,就是打造一个功能共享的世界。

功能共享最重要的交互,于是Android创造出一种Intent和IntentFilter配合的低耦合的交互模型,Intent只是一种描述要完成什么工作跨进程的结构体,而最终如何解析这些Intent并完成其响应,是由IntentFilter来进行换算,最终是由用户来决定如何完成。

而在Intent这种超级交互消息之上,Android进一步把应用程序的实现逻辑拆分成多种特殊的实现:

  • Activity:带显示与交互能力的部分
  • Service:不带显示与交互能力的部分
  • Content Provider:在功能交互之外,提供数据交互能力的部分
  • Broadcast Receiver:用来处理广播交互的部分

这四种功能上的拆分,也体现了Android设计者在设计上抽象思绪能力,即便是随着Android迅猛发展,目前已经到了4.1这么功能丰富、用户体验良好的状态,我们编程也还是与这四种功能组件打交道,可以满足我们任何的编程时所需要的任何行为。

而这四种基本组件组成部分,使Android应用程序反倒成了一个“空壳子”。静态上看,应用程序只是一种包装这些功能的容器;从运行态来看,所谓的应用程序,也只是承载某些功能的进程。

1.1 所谓的Android应用程序

我们从前面的例子中看到,无论是编写的代码,还是最后生成的.apk文件,都是没有所谓的应用程序的。应用程序本身是一种虚无的概念,只是一种以zip格式进行压缩的一个文件,一种容器而已。

如我们前面的Helloworld的例子里所看到的那样,其实一个应用程序里最重要的一个配置文件就是AndroidManifest.xml文件。一个最简单的项目,除了基本的代码与UI资源,也会需要有个AndroidManifest.xml文件。甚至一些极端一点的例子,我们去市场上下载一些什么主题包、插件包、权限包之类的.apk文件,解压开,这时可以发现这样的.apk文件里,连代码都没有,只有一些图片之类的文件。

于是,我们可以得到Android里关于应用程序的第一个印象,作为Android应用程序的载体,.apk文件只是一种进行包装与传输的格式,而每个.apk文件必然包含一个AndroidManifest.xml文件,由这一文件来描述该.apk文件提供的内容。当然,我们在稍后会看到,这一文件里,还会包含一些权限控制的信息。

我们可以给我们的应用程序创建两个一模一样的图形界面,直接从我们的前面的HelloWorld开始下手,比如将HelloWorld.java在Eclipse里拷贝到HelloAgain.java(这样可以减少改代码的麻烦)。这时可以得到两个界面的应用程序,然后我们再把我们的AndoridManifest.xml文件,改成如下的样子:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
     package="org.lianlab.hello"
     android:versionCode="1"
     android:versionName="1.0">
    <applicationandroid:label="@string/app_name">
        <activity android:name=".Helloworld"
                  android:label="@string/app_name">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        <activity android:name=".HelloAgain"
                  android:label="@string/app_name">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>   
    </application>
</manifest>

这时我们编译、安装到Android设备(或者是虚拟机)里,这时再打开主界面查看安装过的应用程序,这时是不是发生了什么很奇怪的现象?这时界面上会出现两个叫Helloworld的应用程序。我们这时如果在设备里去“设置”à “应用程序”,我们仍只看到一个应用程序。

通过对AndroidManifest.xml的小恶作剧,我们可以看到Android应用程序的第二个特点,就是没有所谓的主入口(即我们点击的图标时触发的执行效果)。应用程序在安装完成后,只是通过AndroidManifest.xml来决定在系统上应该表现成什么样子。

如果希望应用程序可以表现不如此变态,这时,我们可以回到AndroidManifest.xml里,把<activityandroid:name=".HelloAgain"…这个标签里的<intent-filter>标签删掉,这时应用程序的表现就正常了。

到目前为止,我们就已经接触了android编程里的两个概念,一个是Activity,另一个是Intent(而我们AndroidManifest.xml文件里的Intent Filter实际是辅助Intent的)。Android毕竟是种图形界面的编程环境,我们常见的应用程序里,可能绝大部分只会与这两种概念打交道。而两者的概念组合,就很容易体现出Android应用程序在编程上的“无界化”思想。

1.2 Android世界里的共享

作为一个智能手机操作系统,其用户可能在功能上有各种各样的功能组合。比如最简单的打电话,则后续动作会有保存联系人,同时需要给联系人拍照做来电大头贴。又比如需要来了个短信通信用户到某个地方干什么事情,这时,用户需要打开地图,搜索一下地址,然后还有可能需要定位到那个位置。

用户在主界面里点击相应功能的应用程序之后,就可能有非常多的功能性的组合,因为用户的想法是不可预估的。我们当然也可以限制用户当前菜单下可以干什么事情,但这样就失去了智能系统的意义。

我们也可以假设用户都会按一个“Home”键回到主界面,这时原来的执行的程序就会被锁定当前状态,用户重新打开另外一个应用程序,操作完再按“Home”键可以退回到原来的应用程序。通过这种“应用程序”到“Home”到“应用程序”的循环,我们也可以达到我们想要达到的目的。但这时,出于交互性的考虑,我们也还是需要有限地提供一些交互手段,比如“短信”应用程序里包含地址信息,一点击可以直接打开“地图”进行后续操作,但这些有限交互是可以在系统设计阶段被固化。这时,我们是不是就得到了我们想要的能够应付用户任何操作组合的系统?是的,恭喜您,您得到了iPhone的设计思路。但此时的用户交互流程则被改变成这个样子:

这种解决问题的办法也不是不可以,但需要很固化的设计,应用程序的行为比较受限。虽然通过横扫全世界的iPhone证明了这样的设计可能是比较合乎用户体验之道的(不容易出错),但这样的解决思路从系统设计角度来看,并不是很灵活。另外一个麻烦是必须要有苹果级设计功底的“Home”键,山寨货则用不了多久就会因为键盘失灵而失效。当然即使苹果级设计,iPhone里的“Home”键还是会失效,于是又不得不在屏幕上加上触摸的Home手势。

作为开源系统的Android,当然不可能基于iOS的交互思路来解决问题,何这种交互时多了一步不停要回到主界面这一步。在Android的设计里,最重要的是能够解决一个应用程序之间进行交互的问题,然后可以实现我们想要在Android系统里完成某种操作时,可以享受从一路顺畅完成的快感。

Android的解决之道,则是将传统意义上的应用程序,细化成一个个完成某项功能的部分,这种功能部分,在Android世界里被称为Activity。Activity都应该被设计成可以独立地被执行以解决某个问题,当它完成或是用户选择退出执行时,又会自动跳回到调用这一Activity的界面,当然这时跳回的位置肯定是另外一个Activity。当然,在一个Android系统里有可能存在无限多的Activity,在他们进行跳转切换时,我们就需要一种很灵活的消息传输机制(因为我们必须兼容系统里所有可能的互相调用的情况)。而且这种传输机制还必须能够跨进程,不然,我们所有的涉及Activity互相调用部分都必须在同一进程里完成。于是,Android系统里又有了Intent,用于解决交互通信。

这样的编程模型也需要有一定前提,那就是我们Application概念必须被弱化,我们不能有main函数入口(如果系统执行依赖main作入口,则不能实现Activity之间互相调用了,所有的Activity执行之前,必须先通过main入口来初始化环境)。出于这样的设计,所以Application必须只是一个容器,将各种不同的Activity实现包装起来加载到系统里。

当然,将功能拆分成一个个的单一功能界面之后,我们需要有种机制可以将用户一路点击过去历史记录下来,当用户处理完时,可以退回到他们之前操作过的界面,这次就可以由多个应用程序组合出像是在用同一个应用程序的效果。有了Activity,有了Activity之间起到调用作用的Intent,这时所有界面间操作变得有点像是函数调用一样,于是我们可以找函数调用时的基本数据结构—栈来帮忙,发生调用时,需要退出的Activity及其状态压栈,当从调用退出时则进行栈的弹出操作,这时我们的Activity管理就演变成如下图所示的简单栈管理。

有了这样的概念,于是我们响应用户点击操作的问题便迎刃而解,我们在设计应用程序时,不再是设计一个复杂的功能实现,而是实现一组完成单项功能的实现,也就是Activity。然后这些Activity,只会通过用户点击来驱动它们之间是如何进行交互的。比如,我们前面看到的地图、搜索、定位三个功能,虽然它都会被包装到同一个地图的应用程序里,但在实现上会是地图、搜索、定位三个不同的Activity。

因为现在我们的界面上的互相调用,已经变成了一种函数式的调用,这样,整个手机上的功能都被切分成各个单一的小功能,而真正要在Android系统上完整地实现某复杂个操作,则会提交由用户的点击来组合生成。这样的复杂功能,则已经不是一个编程上的概念了,在Android系统里,这种需要完成什么事情的操作被抽象成一个虚拟的概念Task。比如我们前面提到的打电话加拍大头贴的操作组合,就构成一个Task,这一Task需要由Launcher.apk,Contacts.apk,Gallery.apk来协同完成

如果我们有两个能够提供同样功能的Activity,这种执行模式的灵活性表现得会更加明显。比如中间打电话的功能,我们系统里有三个Activit(CallScreen, SipPhone, Dialer)都可以完成电话呼叫的功能,这时执行上的路径则会有三种可能性,会在进行跳转时弹出圣对话框由用户来选择:

通过Activity的这种可以动态被用户选择的特点,当用户对某一功能不满意时,完全就有可能通过下载另一个能实现这种功能的应用程序进行替换,甚至可以自己写一个。事实上,Android系统里除了系统状态条与锁屏界面之后,没有任何的不可被替换的功能,这也是Android设备总是会长得千奇百怪的原因之一。

到这时,我们就可以看到Activity之所以会不被称为Window的原因,它也是单个界面或是MVC里的Controller实现部分那么简单,Activity这个名字代表的是某种单一交互功能上的实现。这种功能的实现将在系统里通过Intent串接起来,构成了一个在功能上具备极大可拓展性的系统。基于这样的特点,Android也就被称作是“无边界”系统,因为它在功能上延展不再受限于系统的能力,而只受限于智商与创意。

这就是Android世界里的功能共享。

在这种功能共享模型之下,可能还是会有一些微调的需求:

1. 我们有一些情况下不宜使用这种栈式Activity管理,比如我们写一个需要注册的应用程序,注册完开始使用,然后再按退出,我们又会一步步退回到注册填个人信息的界面,而不合理地完全退出。这样可能不合适。这时,我们可以使用Intent的Flag参数, 加上Activity的Affinity属性进行组合控制。

2. 如果不停地跳出对话框让用户选,用户会崩溃掉。当然,用户可以在选择时点选一个“始终”的默认选择,这时下次就会使用默认的Activity处理某种操作。但还是有可能会不合理地使用跨.apk文件里使用Activity,造成性能上的开销,这时,我们也可以在执行下次Activity执行操作时进行强制性地指定。

当然,我们通过Activity这种概念还需要另外一个前提,这就是Android会有别于传统操作系统的前提,那就是单窗口。想像一下,在多窗口环境下,我们的栈式管理Activity在进行跳转和返回时将会构成多大的灾难啊。好在使用电容屏的设备,单窗口是天生的需

版权声明:本文来源CSDN,感谢博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/zhao3546/article/details/10713797
站方申明:本站部分内容来自社区用户分享,若涉及侵权,请联系站方删除。

0 条评论

请先 登录 后评论

官方社群

GO教程

猜你喜欢