Android开发之最佳实践

这篇文章是对英文文章「Best practices in Android development」的翻译。文章主要为一些Android开发的建议,涉及项目目录结构,IDE,测试框架等内容。这些建议都是Futurice公司安卓开发者实践得出的结论,很有借鉴意义。当然,你可以选择不理会这篇文章提出的建议,只要你觉得你现在的开发习惯更适合你自己,更加有效率。

本文发布于伯乐在线最佳实践之一:安卓开发篇,未经许可,禁止转载。感谢toolate的校稿。以下是译文。

这篇文章主要为Futurice公司Android开发者总结的经验教训。遵循这些规范可以避免无谓的重复劳动。如果对iOS或Windows Phone平台的开发感兴趣,请查看iOS最佳实践文档Windows客户端最佳实践文档

欢迎反馈,但请先阅读反馈规范

总结

  • 使用Gradle和Gradle默认的项目结构
  • 将密码和敏感数据放在gradle.properties
  • 不要实现自己的HTTP客户端,使用Volley或者OkHttp库
  • 使用Jackson库解析JSON数据
  • 由于65K的方法空间限制,避免使用Guava和使用较少的库
  • 用Fragment来显示UI
  • Activity只用来管理Fragment
  • XML也是代码,管理好XML代码
  • 使用样式来减少布局XML代码中重复属性
  • 将样式写在多个文件中,避免把样式全部写在单一的大文件当中
  • 保持colors.xml文件的简短干净,只定义调色板
  • 同样也保持dimens.xml简短干净,只定义通用的常量
  • 避免深层级的ViewGroup
  • 避免客户端处理WebView要显示的内容,并且注意内存泄露
  • 使用Robolectric进行单元测试,使用Robotium进行连接设备(UI)的测试
  • 使用Genymotion模拟器
  • 一直使用ProGuard或者DexGuard

Android SDK

Android SDK存放在home目录或者其他跟应用开发无关的位置。一些IDE在安装时包含了SDK,这时SDK可能存放在IDE的安装目录下。而这是很不好的做法,特别是当你需要升级(或者重新安装)IDE时,或者换一个IDE时。同时也要避免把SDK存放在系统目录下,否则,当普通用户(不是root)使用IDE时就需要获取sudo权限。

编译系统

编译系统首选Gradle。相比于Gradle,Ant更加的局限并且更加繁琐。使用Gradle编译系统可以很简单的做到:

  • 将应用编译成不同的版本
  • 完成简单的类似脚本的任务
  • 管理和下载依赖
  • 自定义秘钥仓库
  • 其他…

Google正积极的开发安卓Gradle插件,作为新的标准编译系统。

项目结构

主要有两个主流的项目结构:旧的Ant项目结构和Eclipse ADT项目结构,较新的Gradle和Android Studio项目结构。当然选择新的项目结构。如果你的项目正在用旧的项目结构,考虑放弃旧的结构,转移到新的项目结构下吧。

旧项目结构:

old-structure
├─ assets
├─ libs
├─ res
├─ src
│ └─ com/futurice/project
├─ AndroidManifest.xml
├─ build.gradle
├─ project.properties
└─ proguard-rules.pro

新的项目结构:

new-structure
├─ library-foobar
├─ app
│ ├─ libs
│ ├─ src
│ │ ├─ androidTest
│ │ │ └─ java
│ │ │ └─ com/futurice/project
│ │ └─ main
│ │ ├─ java
│ │ │ └─ com/futurice/project
│ │ ├─ res
│ │ └─ AndroidManifest.xml
│ ├─ build.gradle
│ └─ proguard-rules.pro
├─ build.gradle
└─ settings.gradle

新旧项目结构最大的不同点是新项目结构更加合理的分开了代码集(main, androidTest)。例如,你可以在代码集src文件夹下添加paid和free文件夹,分别用于存放付费版应用代码和免费版应用的代码。

顶层app文件夹用于将你的应用和其他库(例如:library-foobar)区分开来。Settings.gradle中保存了app/build.gradle需要用到的库的引用。

Gradle配置

普通项目结构。遵循Google安卓Gradle规范
简单任务。可以用Gradle完成一些简单任务,而不用特地去写(shell, Python, Perl等)脚本。具体参考Gradle文档。
密码。你需要在build.gradle中配置应用发行版本的签名配置。以下这些情况是需要避免的:

不要这样做。这些会出现在版本控制系统中。

signingConfigs {
release {
storeFile file("myapp.keystore")
storePassword "password123"
keyAlias "thekey"
keyPassword "password789"
}
}

换一种方式,新建一个gradle.properties文件,文件内容如下。注意,不要把Gradle.properties添加到版本控制系统中。

KEYSTORE_PASSWORD=password123
KEY_PASSWORD=password789

Gradle会自动导入gradle.properties文件,所以你可以在build.gradle中这样写:

signingConfigs {
release {
try {
storeFile file("myapp.keystore")
storePassword KEYSTORE_PASSWORD
keyAlias "thekey"
keyPassword KEY_PASSWORD
}
catch (ex) {
throw new InvalidUserDataException("You should define KEYSTORE_PASSWORD and KEY_PASSWORD in gradle.properties.")
}
}
}

使用Maven管理项目依赖,而不是直接导入jar文件。如果你显式的导入jar文件到项目中,那这些依赖的jar文件只会是某个固定的版本,例如2.1.1。下载jar文件并及时更新jar是很笨的更新依赖的方式。Maven解决了这个问题,并且,Maven可以集成在安卓Gradle编译系统中。你可以指定版本的范围,例如2.2.+,然后Maven就会自动更新到版本范围内的最新版本。例如:

dependencies {
compile 'com.netflix.rxjava:rxjava-core:0.19.+'
compile 'com.netflix.rxjava:rxjava-android:0.19.+'
compile 'com.fasterxml.jackson.core:jackson-databind:2.4.+'
compile 'com.fasterxml.jackson.core:jackson-core:2.4.+'
compile 'com.fasterxml.jackson.core:jackson-annotations:2.4.+'
compile 'com.squareup.okhttp:okhttp:2.0.+'
compile 'com.squareup.okhttp:okhttp-urlconnection:2.0.+'
}

IDE和文本编辑器

不管用什么编辑器,它都必须要能够很好的显示项目结构。编译器的选择看个人喜好,但是编辑器必须要能够显示项目结构和编译。

现在最为推荐的IDE时Android Studio,因为Android Studio由Google开发,最为接近Gradle,默认使用新的项目结构,也终于发布了beta版,可以说是为Android开发量身定做的IDE。

当然你也可以使用Eclipse ADT,但是需要重新配置,因为Ecplise ADT默认使用旧的项目结构和使用Ant编译。甚至,可以使用纯文本编辑器,比如Vim, Sublime Text, 或者Emacs。如果使用纯文本编辑器,就需要在命令行中使用Gradle和adb。如果Eclipse集成Gradle后仍旧不能工作,你可以选择在命令行中编译,或者迁移至Android Studio。

不管使用什么IDE和文本编辑器,确保使用Gradle和新的项目结构来编译应用程序,同时避免把编译器的配置文件添加到版本控制系统当中。例如,避免添加Ant的配置文件build.xml。还有需要强调的一点,如果你在Ant中更改了编译配置,不要忘记更新build.gradle,使其能够完成编译。另外,对其他的开发者友好一点,不要强迫他们去改变他们的工具的偏好设置。

Jackson是一个用于将对象转换成JSON或者将JSON转换成对象的Java库。为了解决JSON和对象相互转换的问题,Gson是一个受欢迎的选择。但是我们发现,自从Jackson支持多种JSON处理方式:流,内存中的树模型和传统的JSON-POJO数据绑定,Jackson更加高效。请记住,Jackson是一个比GSON大的库,所以请根据你自己的实际情况做出选择。考虑到65K的方法空间限制,你可能会偏向于选择GSON。其他选择:Json-smartBoon JSON

网络,缓存和图片。现在已经有许多经过实践证明的向后端服务器请求数据的解决方案。你应该考虑使用这些解决方案来实现自己的客户端。使用Volley或者Retrofit。Volley也提供了加载和缓存图片的帮助类。如果你选择Retrofit,考虑使用Picasso来加载和缓存图片,使用OkHttp来实现高效的HTTP请求。Retrofit,Picasso和OkHttp都由同一个公司实现,所以这三者契合的特别好。OkHttp也可以和Volley配套使用

RxJava是一个用于响应式编程的库,也即是,处理异步事件的库。RxJava非常强大,也是一个很好的范例(promising paradigm)。RxJava非常与众不同,因此使用RxJava时可能会令人迷惑。我们推荐在把RxJava部署到整个应用前先花一些时间了解RxJava。现在已经有一些项目是利用RxJava来完成的,如果你需要帮助,请向这些人询问:Timo Tuominen, Olli Salonen, Andre Medeiros, Mark Voit, Antti Lammi, Vera Izrailit, Juha Ristolainen。另外,我们也写了一些博客:[1], [2], [3], [4].

如果你没有使用Rx的经验,请从应用Rx的响应API开始。或者,从应用Rx的UI事件处理开始,比如点击事件或者在搜索框中的键盘事件。如果你对使用Rx很有信心,想要把Rx应用到整个应用程序当中,请在比较难处理,容易令人迷惑的部分写明Javadocs。记住,其他不熟悉RxJava的程序员维护项目时可能会非常困难。请尽力去帮助他去理解你的代码和Rx。

Retrolambda是一个在Android平台或者其他低于JDK8的平台上处理Lambda表达式语法的Java库。利用这个库,可以保持你的代码的整洁严谨并且具有可读性,特别是当你使用了函数式样式(functional style),例如使用了RxJava。使用前,先安装JDK8,在Android Studio项目结构对话框中将它设置为你的SDK路径,设置JAVA8_HOME和JAVA7_HOME环境变量,然后在项目根目录下build.gradle中增加以下内容:

dependencies {
classpath 'me.tatarka:gradle-retrolambda:2.4.+'
}

然后在每一个模块下的build.gradle中,增加以下内容:

apply plugin: 'retrolambda'
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
retrolambda {
jdk System.getenv("JAVA8_HOME")
oldJdk System.getenv("JAVA7_HOME")
javaVersion JavaVersion.VERSION_1_7
}

Android Studio支持对Java8的lambda智能提示。如果你是第一次使用lambda,从以下两条规则开始:

  • 所有只有一个方法的接口都是「lambda友好」的,能够被转换成更加整洁严谨的语法。
  • 如果你不确定参数或者其他信息,写一个普通的匿名内部类,然后让Android Studio将它转换成一个lamdba表达式。

请注意dex方法限制,避免使用过多的库。被打包成dex文件的安卓应用,都有一个硬性的限制:最多能有65536个方法引用[1] [2] [3]。如果你超出了这个限制,在编译的时候你就会看到一个严重的编译错误。因此,使用尽量少的库,并使用dex-method-counts工具来决定在保证不超出限制的前提下,有哪些库可以使用。特别要避免使用Guava库,因为它包含了超过13k个方法。

Activities and Fragments

在Android应用开发中,首选Fragment来显示UI。Fragment是可重用的用户交互界面,并且可以将Fragment组合在一起。我们推荐使用Fragment来显示用户交互界面,而不是使用Activity。以下是一些理由:

  • 实现多视图布局。将手机应用扩展至平板的主要方法便是利用Fragment。利用Fragment,可以让视图A和B都显示在一个平板屏幕上,而在手机屏幕上,视图A和B都占一整块屏幕。如果你的应用从一开始就用Frament来实现,那么你很容易就能将你的应用适配到屏幕大小不同的设备上。
  • 屏与屏之间的通信。安卓API并没有提供一个恰当的方法将复杂的数据(例如,一些Java对象)从一个Activity发送到另外一个Activity中。但是利用Fragment时,以activity实例为通信管道,可以实现该activity下的子fragment之间的通信。即使这种方法优于Activity之间的通信,你可能仍旧需要一个事件总线的架构,考虑使用Otto或者greenrobot EventBus
  • Fragment有更好的普适性,而不仅仅只是实现UI。你可以实现一个没有UI的fragment,作为activity后台运行的「工人」。你也可以将这个点子发挥的更淋漓尽致一点,比如创建一个fragment专门用于实现fragment的改变逻辑,而不是将这些逻辑写在activity中。
  • 甚至ActionBar也可以在fragment中管理。你可以创建一个没有UI的fragment,只用于管理ActionBar,或者在每一个当前可见的fragment中把自己需要的action项添加到父activity的ActionBar上。阅读更多内容。

虽然我们建议使用fragment,但是我们不建议大量使用嵌套的fragment,因为可能会引起matryoshka bugs。只在合理的情况下(例如,水平滑动的ViewPager中的fragment嵌套在一个模拟屏幕的fragment中)或者经过深思熟虑时,才使用嵌套的fragment。

从架构层面来讲,你的应用应该有一个顶层的activity,其中activity中包含了大部分的业务相关的fragment。你也可以有其他的辅助activity,只要这些activity和主activity的通信足够简单,能够通过Intent.setData()或者Intent.setAction()或者其他简单的方式实现即可。

Java包结构

Android应用程序的Java包结构可以用基本上近似于模型-视图-控制器结构。对于Android,Fragment和Activity实际上就是控制类。同时,这两者也是用户交互界面的一部分,因此,这两者也是视图。

由于上述原因,将fragment(或者activity)严格的归类为控制器或者是视图是非常困难,不合理的。所以,更合理的做法是把fragment存放在专有的fragment包内。如果你遵循了前一部分的建议,那么可以将activity存放在最顶层的包下。如果你计划创建多于2个或3个activity,那么创建一个activities包。

否则(译者注:如果没有fragment和activity),包结构看起来就是一个典型的MVC结构。有一个models包,存放主要用于JSON解析时API返回值的POJO对象;一个views包,存放你自定义的视图,通知,action bar视图和小部件等。Adapter的归类比较模糊,是处于数据和视图之间的位置。但是,一般情况下,adapter需要在getView()函数中引入一些视图,所以可以在views包下建一个adapters包来存放adpater。

一些控制类是整个应用程序都需要使用到的,也更加接近安卓系统底层。这些控制类存放在managers包下。各种数据处理类,例如「DateUtils」,存放在utils包下。负责与后端服务器进行交互的类存放在network包下。

总之,按靠近后端服务器到靠近用户的顺序排列,包结构如下:

com.futurice.project
├─ network
├─ models
├─ managers
├─ utils
├─ fragments
└─ views
├─ adapters
├─ actionbar
├─ widgets
└─ notifications

资源

命名。遵循以类型作为前缀的习惯,像type_foo_bar.xml。例如:fragment_contact_details.xml,view_primary_button.xml,activity_main.xml。

管理好布局XML代码。如果你不确定如何按照一定的格式来管理XML,可以参考以下几个习惯:

  • 一个属性占单独的一行,缩进4个空格
  • android:id总是第一个属性
  • android:layout_****属性放在顶部
  • style属性放在底部
  • 标签关闭/>独占一行,便于调整属性的顺序和增加属性
  • 不要在android:text中硬编码字符串,考虑使用Android Studio中提供的Designtime attributes功能
    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout
    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"
    android:orientation="vertical"
    >
    <TextView
    android:id="@+id/name"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_alignParentRight="true"
    android:text="@string/name"
    style="@style/FancyText"
    />
    <include layout="@layout/reusable_part" />
    </LinearLayout>

最重要的规则是,在布局XML中定义android:layout_****属性,而其他的android:****属性则在样式XML中定义。这个规则有例外的情况,但是大部分情况下是适用的。这个规则保证只有layout属性(positioning, margin, sizing)和内容属性在布局文件中,其他的外观属性(colors, padding, font)则定义在样式文件中。

例外的情况有:

  • android:id显然应该在布局文件中定义
  • LinearLayout的android:orientation属性在布局文件中定义更为合理
  • android:text应该在布局文件中定义,因为它定义了特定的内容(译者注:属于内容属性)
  • 有时候创建通用的样式文件来定义android:layout_widthandroid:layout_height更加合理,但是一般情况下这两个属性应该在布局文件中定义。

使用样式。在项目中,重复的view的外观(译者注:重复的view属性)是很常见的,因此,基本上每个项目都需要恰当的使用样式。在一个应用程序中,至少应该有一个通用的文本内容的样式。例如:

<style name="ContentText">
<item name="android:textSize">@dimen/font_normal</item>
<item name="android:textColor">@color/basic_black</item>
</style>

应用到TextView当中如下:

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/price"
style="@style/ContentText"
/>

你也可能需要给button按钮写一个通用的样式, 不过不要只停留在给文本内容和按钮写通用样式上。继续的深入应用这个思想,把View的相关的重复的属性写成通用的样式。

把大的样式文件分成多个小样式文件。你不一定非得只有一个styles.xml文件。Android SDK支持以非传统方式命名的样式文件。文件名styles并没有特别的作用,起作用的只是文件中的XML标签<style>。因此,一个项目中可以同时有这些样式文件styles.xml, styles_home.xml, styles_item_details.xml, styles_forms.xml。不像资源目录名那样在编译时有特殊意义,在res/values下的文件名是任意的。

colors.xml是颜色调色板。colors.xml中应该只包含一些颜色名字到RGBA颜色值的映射。不要在colors.xml中为不同的按钮定义不同的颜色。
不要像下面这样做:

<resources>
<color name="button_foreground">#FFFFFF</color>
<color name="button_background">#2A91BD</color>
<color name="comment_background_inactive">#5F5F5F</color>
<color name="comment_background_active">#939393</color>
<color name="comment_foreground">#FFFFFF</color>
<color name="comment_foreground_important">#FF9D2F</color>
...
<color name="comment_shadow">#323232</color>

如果你像上面这种形式来定义颜色,你很快便开始定义重复的RGBA颜色值。这种情况下,需要改变基础色值时,工作将会变得非常复杂。并且,这些颜色定义跟上下文有关,像”button”和”comment”这些,应该在按钮的样式文件中定义,而不是在colors.xml中定义。

你可以这样做:

<resources>
<!-- grayscale -->
<color name="white" >#FFFFFF</color>
<color name="gray_light">#DBDBDB</color>
<color name="gray" >#939393</color>
<color name="gray_dark" >#5F5F5F</color>
<color name="black" >#323232</color>
<!-- basic colors -->
<color name="green">#27D34D</color>
<color name="blue">#2A91BD</color>
<color name="orange">#FF9D2F</color>
<color name="red">#FF432F</color>
</resources>

像应用程序的设计者要这份颜色调色板。名字不一定非得是颜色的名字,例如green, blue等。像brand_primary, brand_secondary, brand_negative这中类型的名字也是完全可以接受的。以这种格式来管理颜色,在改变颜色值的时候会很方便,同时也可以很直观的看到使用了多少个不同的颜色。如果要展现一个漂亮的UI界面,减少颜色种类的使用是很重要的一点。

避免深层级视图。有时候,你想要在原有的视图xml中添加一个新的LinearLayout,以此来实现一个新的视图。那么,很有可能发生下面的情况:

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
>
<RelativeLayout
...
>
<LinearLayout
...
>
<LinearLayout
...
>
<LinearLayout
...
>
</LinearLayout>
</LinearLayout>
</LinearLayout>
</RelativeLayout>
</LinearLayout>

即使你没有直接在一个布局文件中看到上述的情况,当你在把一个视图填充(在Java代码中)到另一个视图中时,上述的情况也有可能发生。
这可能引发一系列的问题。可能会有性能问题,因为在这种情况下,处理器需要处理非常复杂的UI树。另外一个更严重的错误是栈溢出错误
因此,尽可能的减少视图的层级:学习如何使用RelativeLayout,如何优化布局和如何使用<merge>标签

谨慎处理与WebView相关的问题。当你必须显示一个网页时,例如一篇新闻,不要在客户端中处理HTML,更好的做法是向后端程序员请求”纯净”的HTML代码。当你把WebView绑定到activity上,而不是绑定到ApplicationContext上时,WebView也可能会泄露内存。不要使用WebView来展现简单文字或者按钮,用TextView和Button来实现。

测试框架

Android SDK提供的测试框架仍旧不够完善,特别是UI测试。Android Gradle现在利用一个为安卓定制的JUnit帮助工具插件,实现了一个测试框架connectedAndroidTest来执行你创建的JUnit测试。也就是说,在进行测试时,你需要连接设备或者模拟器。请根据官方的测试指南[1] [2]来操作。

Robolectric只用于单元测试,不用于视图UI测试。为了保证开发速度,Robolectric这个测试框架致力于提供不连接设备时的测试,也即是适合于对模型和视图模型的单元测试。但是,在Robolectric的框架下测试UI是不准确,不完全的。在测试和动画,对话框相关的UI元素时,你可能会遇到一些问题。由此,你”坠入了深渊”(测试过程中看不到控制屏幕),这使测试变得非常复杂。

Robotium让写UI测试变得非常容易。在Robotium测试框架下测试UI,你不需要进行连接设备的测试,但是利用Robotium提供的大量的帮助工具,你可以非常方便的分析视图UI和控制屏幕。测试用例也非常简单,以下是一个例子:

solo.sendKey(Solo.MENU);
solo.clickOnText("More");
// searches for the first occurence of "More" and clicks on it
solo.clickOnText("Preferences");
solo.clickOnText("Edit File Extensions");
Assert.assertTrue(solo.searchText("rtf"));

模拟器

如果你以开发安卓应用为职业,那么买一个正版的Genymotion模拟器吧。相比于AVD模拟器,Genymotion模拟器具有更高的帧率。它提供了一些工具来演示你的应用,模拟网络连接质量,GPS定位等。当然,Genymotion也适合于进行连接设备的测试。(译者注:为了全面的测试)你需要买很多(但不是全部)不同的设备,因此花钱买一个正版的Genymotion模拟器会比买很多物理设备便宜很多。

注意:Genymotion模拟器不会实现所有的谷歌服务,例如Google Play商店和地图。如果你需要测试三星独有的API,那还是有必要买一个三星的设备。

Proguard配置

一般情况下,ProGuard用于缩减和混淆安卓项目的打包代码。

是否使用Proguard取决于你的项目配置。大部分情况下,当你编译一个发行版本的apk时,你需要配置gradle来运行ProGuard。

buildTypes {
debug {
runProguard false
}
release {
signingConfig signingConfigs.release
runProguard true
proguardFiles 'proguard-rules.pro'
}
}

为了判断要保留哪些代码,忽略或者混淆哪些代码,你必须明确的指出一个或者多个代码入口。 这些代码入口一般为包含有main函数的类,Java小程序(applet),移动信息设备小程序(Midlet),activity等。你在SDK_HOME/tools/proguard/proguard-android.txt可以找到安卓框架提供的默认配置。每个项目在my-project/app/proguard-rules.pro中自定义的proguard规则,(译者注:执行proguard时)会被附加到默认配置上。

有一个跟ProGuard相关的常见问题,在应用程序启动时因为ClassNotFoundException或者NoSuchFieldException或者类似的异常而崩溃,即使你在编译命令行(例如,assmbleRelease)中成功的完成编译并且没有warning提示。不外乎以下两种情况:

  1. ProGuard认为一些类,枚举,方法,变量或者注解不需要,将其移除了。
  2. ProGuard混淆了类,枚举,或者变量,但是这些类可能被通过它原来的名字间接地调用了,例如,通过Java的反射机制调用。

查看app/build/outputs/proguard/release/usage.txt,看造成崩溃问题的对象是否被移除了。
查看app/build/outputs/proguard/release/mapping.txt,看造成崩溃问题的对象是否被混淆了。

为了防止ProGuard剔除需要用到的类或者类成员,在你的proguard配置中添加一个keep项:

-keep class com.futurice.project.MyClass { *; }

为了防止ProGuard混淆一些类或者类成员,添加一个keepnames项:

-keepnames class com.futurice.project.MyClass { *; }

在这份ProGuard配置模板中有一些例子。在ProGuard文档中有更多的例子。

提示:把每一个发行版本的mapping.txt文件都保存下来。这样,当用户遇到一个bug,提交了一个混淆的调用栈时,便可以根据保存的mapping.txt来调试,找到问题所在。

DexGuard。如果你需要一个不错的工具来优化代码,特别是经过混淆的发行版代码,考虑使用DexGuard。DexGuard是有ProGuard团队做的一个商业软件。利用DexGuard,可以很容易的分割Dex文件,解决了65k方法空间限制的问题。

感谢

感谢Antti Lammi, Joni Karppinen, Peter Tackage, Timo Tuominen, Vera Izrailit, Vihtori Mäntylä, Mark Voit, Andre Medeiros, Paul Houghton和其他Futurice开发者分享关于安卓开发的知识。

许可

Futurice Oy Creative Commons Attribution 4.0 International (CC BY 4.0)

您的支持将鼓励我继续创作。