Android快速入门

距离上一次写Android代码已经过去了三四年了,基本上都忘得差不多了,最近在尝试使用kotlin开发一个App,发现查询很多资料都不太全,浪费了大量的时间,因此决定整理一篇在Android入门中经常遇见的需求和问题,希望能对刚接触Android的朋友有一点帮助。

<!--more-->

本文包含大量的参考链接,可以先看一下文章的总结,如果有疑惑再点击参考链接,否则跳转太多可能会影响学习效率。

此外还包含大量的示例代码,大部分使用kotlin编写,可以参考 koltin基础教程

本文应该会陆续更新一段时间,直至把这段时间的Android学习任务搞完。

参考

1. UI布局

Android主流布局是xml,当然也可以直接使用代码创建view,此外目前的jetpack compose使用的声明式布局已经很流行了,未来可能是它和swift ui的天下,不过对于我们初学者,了解下xml布局也还是有点用的。

相比较之前iOS只能通过代码生成UIView的情形,安卓的xml布局已经是比较大的体验改善了。

1.1. 三大基础布局

参考:https://www.jianshu.com/p/422c55f1f08e

  • 线性布局,LinearLayout是在父布局中把所有的子控件按线性排列,有按行和列两种排列方式
  • 相对布局,RelativeLayout布局中,子控件采用相互参照的方式确定自身的位置。可以采用相对于父控件或者其他子控件的方式,使用方式较灵活
  • frame布局,FrameLayout中子控件开始全部堆砌在父视图的左上角,通过设置子控件的属性gravity来调整位置。

如果之前先有web开发经验(比如我自己),可能第一思维是使用线性布局模仿文档流,但实际上相对布局可以减少标签嵌套,需要改变一下思维

1.2. ConstraintLayout

参考

可以使用约束在一个无嵌套的标签列表中编写复杂的UI布局,缺点是需要大概率需要借助可视化编辑器,否则手写会十分非常麻烦,同时需要记得在布局之前先把view的id给取好名称,不然后续再从各个约束依赖里面修改id,非常痛苦

1.3. 动态创建view

在大多数时候,页面都不会是纯静态的,需要我们根据数据动态添加view,比如渲染一个列表之类的

val badgeContainer = v.findViewById<FrameLayout>(R.id.badgeContainer) // 获取父节点
for (item in list) {
    val iv = ImageView(activity) // 传入context
    val size = 10
    val params = FrameLayout.LayoutParams(size, size) // 这里需要对应的父节点的LayoutParams

    iv.layoutParams = params
    iv.translationX = item.x
    iv.translationY = item.y
    iv.setImageResource(R.drawable.circle_shape)

    iv.adjustViewBounds = true

    badgeContainer.addView(iv)
}

首先生成view,然后调用父节点的addView就可以了

当然系统内置的很多组件提供了传入动态数据的接口,如ListItemAdapter等。

1.4. 组合view

在某些需要将系统view近一步封装时,除了fragment,也可以使用组合view

下面演示一个自定义titleBar组件的UI

首先定义xml布局模板

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/titleBar"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="horizontal">

    <TextView
        android:id="@+id/titleBarLeft"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="返回" />

    <TextView
        android:id="@+id/titleBarMid"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:textAlignment="center"
        android:text="标题" />

    <TextView
        android:id="@+id/titleBarRight"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="右侧" />
</LinearLayout>

如果我们期望传入一些类似于Prop的配置参数,需要先定义

res/values目录下创建一个attrs_title_bar.xml的文件,下面定义了一个title_text

<resources>
    <declare-styleable name="TitleBar">
        <attr name="title_text" format="string" />
    </declare-styleable>
</resources>

然后编写一个继承自根节点LinearLayout的view,完成初始化逻辑

  • 初始化参数
  • 初始化view
class TitleBar @JvmOverloads
constructor(
    private val ctx: Context,
    private val attributeSet: AttributeSet? = null,
    private val defStyleAttr: Int = 0
) : LinearLayout(ctx, attributeSet, defStyleAttr) {
    lateinit var titleView: TextView

    var title: String = "default title"

    init {
        initAttrs()
        initView()
    }

    private fun initAttrs() {
        val mTypedArray = context.obtainStyledAttributes(attributeSet, R.styleable.TitleBar)
        title = mTypedArray.getString(R.styleable.TitleBar_title_text).toString();
        mTypedArray.recycle()
    }

    private fun initView() {
        LayoutInflater.from(ctx).inflate(R.layout.view_titlebar, this, true);
        titleView = findViewById(R.id.titleBarMid)

        titleView.setText(title)
    }
}

最后,就可以在其他布局中使用啦

<com.example.test.widget.TitleBar
        android:layout_width="match_parent"
        android:layout_height="40dp"
        app:title_text="hello"
        >

1.5. 自定义View

参考:https://blog.csdn.net/aigestudio/article/details/41799811,爱哥的自定义控件系列,虽然时间有点久了,仍值得阅读。

自定义view,看起来就是直接控制底层的绘制API,把无法通过系统内置组件实现的UI给绘制出来,初学先不深究。

1.6. 动画效果

参考:https://blog.csdn.net/carson_ho/article/details/72827747

通过startAnimation方法

Button mButton = (Button) findViewById(R.id.button_head);

Animation translateAnimation = new TranslateAnimation(0, 500, 0, 0);
// 步骤2:创建平移动画的对象:平移动画对应的Animation子类为TranslateAnimation
// 参数分别是:
// 1. fromXDelta :视图在水平方向x 移动的起始值
// 2. toXDelta :视图在水平方向x 移动的结束值
// 3. fromYDelta :视图在竖直方向y 移动的起始值
// 4. toYDelta:视图在竖直方向y 移动的结束值

translateAnimation.setRepeatCount(-1);
translateAnimation.setDuration(3000);
// 固定属性的设置都是在其属性前加“set”,如setDuration()
mButton.startAnimation(translateAnimation);

如果需要实现复杂动画,也可以考虑使用lottie的动画库,初学先不深究。

2. Activity相关

2.1. activity生命周期

class LifeActivity : AppCompatActivity() {
    private val Tag: String = "LifeActivity"
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_life)
    }

    override fun onStart() {
        super.onStart()
        Log.i(Tag, "onStart")
    }

    override fun onResume() {
        super.onResume()
        Log.i(Tag, "onResume")

    }

    override fun onPause() {
        super.onPause()
        Log.i(Tag, "onPause")
    }

    override fun onStop() {
        super.onStop()
        Log.i(Tag, "onStop")
    }

    override fun onDestroy() {
        super.onDestroy()
        Log.i(Tag, "onDestroy")
    }
}

需要注意的一些细节

点击后退键返回上一页,调用onPauseonStoponDestroy`,当前activity被销毁,相当于告诉系统:不需要这个activity了

点击home键,调用onPauseonStop,相当于告诉系统:我切出去看看其他的,稍后可能回来,因此不会调用onDestroy,但是需要注意系统在低内存时可能会销毁这些被停止的activity

设备旋转时,会销毁当前activty,然后重新创建一个,因此如果需要保存数据,则可以通过onSaveInstanceState将数据保存在bundle中,这样,新的activity在onCreate的时候可以拿到对应的bundle并恢复到对应状态

只有调用了onStop之后的activity才会被销毁,在此之前,会先调用onSaveInstanceState,通过系统将对应的activity暂存记录保存起来,下次activity再onCreate时可以使用暂存记录重新创建。

private val key1: String = "key1"
override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    outState.putInt(key1, 1)
}

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_life)
    if(savedInstanceState!=null){
        val val1 = savedInstanceState.getInt(key1)
    }
}

2.2. activity携带参数跳转

通过intent跳转到新的activity,同时可以通过Bundle携带参数

// activity1
fun toPage(){
    val intent = Intent(this@Activity1, Activity2::class.java)
    val bundle = Bundle()

    // 携带相关的参数过去,上报数据的时候一起提交
    bundle.putString("driverName", inputDriverName.text.toString())
    bundle.putString("trainCode", spinnerTrainCode.selectedItem.toString())
    bundle.putBoolean("isPreviewTrain", previewToggle.isChecked)
    intent.putExtras(bundle)

    startActivity(intent)
}

然后在目标Activity2中,可以通过intent属性获取到对应的数据

// activity2
// 获取初始化参数
driverName = intent.getStringExtra("driverName")
trainCode = intent.getStringExtra("trainCode")
isPreviewTrain = intent.getBooleanExtra("isPreviewTrain", true)

2.3. 使用fragment

显然,每个页面都使用Activity进行构建是可以的,但是肯定存在多个Activity复用一些布局和逻辑的场景,这时候可以使用fragment

参考

首先需要定义fragment

先来个布局,类似于一个小型的layout xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <!--中间是其他布局-->
</RelativeLayout>

然后定义Fragment子类,需要完成绑定布局,定义初始化参数等逻辑

private const val ARG_PARAM1 = "param1"
private const val ARG_PARAM2 = "param2"

class DemoFragment : Fragment() {
    private var param1: Boolean = true
    private var param2: String? = null

    private lateinit var imageTrain: ImageView
    private lateinit var badgeContainer: FrameLayout

    private lateinit var callback: OnTrainListener

    // 转换初始化参数
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        arguments?.let {
            param1 = it.getBoolean(ARG_PARAM1)
            param2 = it.getString(ARG_PARAM2)
        }
    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // 绑定布局
        val v: View = inflater.inflate(R.layout.fragment_demo, container, false)
        return v
    }

    // 扩展一个newInstance方法,用于接收参数
    companion object {
        @JvmStatic
        fun newInstance(param1: Boolean, param2: String) =
            TrainFragment().apply {
                arguments = Bundle().apply {
                    putBoolean(ARG_PARAM1, param1)
                    putString(ARG_PARAM2, param2)
                }
            }
    }
}

fragment的生命周期与activity的方法类似,一个关键区别就在于,fragment的生命周期方法由托管activity而不是操作系统调用

然后就可以在activity中使用fragment了

首先在layout的xml中留一个占位标签

  <FrameLayout
        android:id="@+id/fragmentContainer"
        android:layout_width="match_parent"
        android:layout_height="300dp" />

然后在onCreate中通过FragmentManager添加fragment,可以通过上面的newInstance传入初始化参数

private var fragment: Fragment?
private fun initFragment() {
    val fm: FragmentManager = supportFragmentManager

    fragment: Fragment? = fm.findFragmentById(R.id.trainContainer)

    if (fragment == null) {
        fragment = DemoFragment.newInstance(isPreviewTrain, "p2")
        fm.beginTransaction()
            .add(R.id.trainContainer, fragment)
            .commit()
    }
}

通过构造参数可以完成activity向fragment的通信,如果fragment需要通知activity,可以通过约定接口来实现,大致实现为:通过在fragment中定义接口,在activity中实现接口,然后由fragment获取activity的实例执行即可

// fragment
class DemoFragment : Fragment() {
    // 暴露接口
    interface OnDemoListener {
        fun onBtnClick(params: String)
    }

    // 提供一个接口,用于获取activity实例
    fun setOnTrainListener(callback: OnTrainListener) {
        this.callback = callback
    }

    // 调用activity实例的方法
    fun btnClick(){
        this.callback.onBtnClick("test")
    }
}

// acitivty
class MainActivity : AppCompatActivity(), DemoFragment.OnDemoListener {
    // 添加fragment的时候将自己暴露给fragment
    override fun onAttachFragment(fragment: Fragment) {
        if (fragment is TrainFragment) {
            fragment.setOnTrainListener(this)
        }
    }

    // 实现OnDemoListener相关的接口
    override fun onBtnClick() {
        // 做点事情
    }
}

3. 消息通信机制

在初学的时候经常遇见在非UI线程中操作UI然后导致App崩掉的问题,要一劳永逸地解决这个问题,需要先了解Android中的消息机制。Android有两种消息机制

  • 组件间消息:Intent 机制
  • 线程间消息:Message 机制

3.1. Intent:组件间通信

参考:Intent 和 Intent 过滤器 - 官方文档

Intent 是一个消息传递对象,主要用来从其他应用组件请求操作,在系统的各种交互都可以理解为操作,如打开文件选择题,打开摄像头拍照之类的,

下面展示一个通过intent选择文件的例子,实际上就是通过隐式intent打开了文件选择Activity

fun pickFile() {
    val intent = Intent(Intent.ACTION_GET_CONTENT)
    intent.addCategory(Intent.CATEGORY_OPENABLE)
    intent.type = "*/*"
    this.startActivityForResult(intent, REQUEST_CODE)
}

// 获取文件的真实路径
override fun onActivityResult(requestCode: Int, resultCode: Int, @Nullable data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (data == null) {
        // 用户未选择任何文件,直接返回
        return
    }
    val uri: Uri? = data.data // 获取用户选择文件的URI
    if(uri == null) {
        return
    }
    // 通过ContentProvider查询文件路径
    val resolver = this.contentResolver
    val cursor: Cursor? = resolver.query(uri, null, null, null, null)
    if (cursor == null) {
        // 未查询到,说明为普通文件,可直接通过URI获取文件路径
        val path: String? = uri.getPath()
        return
    }
    if (cursor.moveToFirst()) {
        // 多媒体文件,从数据库中获取文件的真实路径
        val path: String = cursor.getString(cursor.getColumnIndex("_data"))
        // todo 获取path之后,就可以拿去上传图片了
    }
    cursor.close()
}

3.2. Message:线程通信

参考

JS是单线程的,因此在前端开发中很少接触到线程的概念,而Android是多线程的,分为主线程(UI线程)和子线程,Handler 是用来切换线程的

消息机制的工作流程分三步进行:

1、Handler 调用 sendMessage 将 Message 入队到 MessageQueue 中;

2、Looper 类的 loop() 方法中无限循环,从 MessageQueue 中取出 Message ,再交回给 Handler ;

3、Handler 调用 dispatchMessage 分发处理消息。

具体的使用流程大致为

// 先在当前线程创建一个handler,同时注册消息处理方法
private val hanlder = Handler {  
 val b = when (it.what) {  
  1 -> true  
  else -> false  
 }  
 b  
}
// ... 当前线程有个messqgeQueue,循环从队列中取出消息通知handler

// 开启新线程,内部使用hander发送消息通知前面的线程
Thread{  
 val msg = Message()  
 msg.what = 1  
 hanlder.sendMessage(msg)  
}

回头来看,子线程与UI线程的通信有下面几种方式,参考:https://blog.csdn.net/liugec/article/details/78731626

  • Activity.runOnUIThread(Runnable)
  • View.Post(Runnable)和View.PostDelayed(Runnabe,long)
  • AsyncTask
  • Handler.Post(Runnabe)和Handler.PostDelayed(Runnabe,long)

实际上activity的runOnUiThread或者view.post就是对于handler的封装

runOnUiThread,顾名思义,在UI线程执行

Thread{  
 // 子线程处理逻辑
 runOnUiThread{  
   // 更新UI线程
 }  
}.start()

view.post,常用来在onCreate中获取view尺寸,也可以在子线程中调用用来更新UI

view.post()  // 会切换到主线程的mHandler

此外,也可以自定义looper

  • Looper.prepare,之后创建的Handler会跟这个自定义的looper绑定,
  • Looper.loop()开始循环获取消息队列中的消息
  • Looper.myLooper()?.quit()退出消息队列,Thread才会向后面执行

因此下面的代码输出为A、B1、B2、C、D

Thread {
    Log.e(TAG, "A")
    Looper.prepare()
    Handler().post{
        Log.e(TAG, "B1")
    }
    Handler().post{
        Log.e(TAG, "B2")
        Looper.myLooper()?.quit()
    }
    Looper.loop()
    Log.e(TAG, "C")
    runOnUiThread {
        Log.e(TAG, "D")
    }
}.start()

Handler创建的时候会采用当前线程的Looper来构造消息循环系统,Looper在哪个线程创建,就跟哪个线程绑定,并且Handler是在他关联的Looper对应的线程中处理消息的。(敲黑板) 那么Handler内部如何获取到当前线程的Looper呢—–ThreadLocal。ThreadLocal可以在不同的线程中互不干扰的存储并提供数据,通过ThreadLocal可以轻松获取每个线程的Looper。当然需要注意的是 ①线程是默认没有Looper的,如果需要使用Handler,就必须为线程创建Looper。我们经常提到的主线程,也叫UI线程,它就是ActivityThread, ②ActivityThread被创建时就会初始化Looper,这也是在主线程中默认可以使用Handler的原因。

4. 网络相关

4.1. okhttp网络请求

参考:okhttp

首先添加依赖,最新的已经出到v4版本了,这里还是使用的v3.14版本

dependencies {
    implementation 'com.squareup.okhttp3:okhttp:3.14.4'
}

然后封装相关的请求

object ApiUtil {
    private val host = "http://192.168.0.4:7001"

    // 基础的请求
    fun sendRequest(callback: Callback) {
        val client = OkHttpClient()
        val request = Request.Builder()
            .url("${host}/api/feedback")
            .build()
        client.newCall(request).enqueue(callback)
    }

    // 上传文件
    fun uploadFile(filePath: String, onSuccess: (String) -> Unit) {
        val url = "${host}/api/upload"
        val file = File(filePath) // 图片和视频都可以使用File进行上传
        val fileBody = RequestBody.create(MediaType.parse("application/octet-stream"), file)
        val requestBody = MultipartBody.Builder()
            .setType(MultipartBody.FORM)
            .addFormDataPart("file", file.name, fileBody)
            .build()

        val request = Request.Builder()
            .url(url)
            .post(requestBody)
            .build()

        val httpBuilder = OkHttpClient.Builder()
        val okHttpClient = httpBuilder
            .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
            .writeTimeout(120, java.util.concurrent.TimeUnit.SECONDS)
            .build()
        okHttpClient.newCall(request).enqueue(object : Callback {
            override fun onFailure(call: Call, e: IOException) {
                Log.i("shymean", "upload file error, ${e}")
            }

            override fun onResponse(call: Call, response: Response) {
                var json = response.body()?.string()
                // 通过gson转换成bean
                val res = Gson().fromJson(json, UploadResponse::class.java)
                if (res.data[0] != null) {
                    onSuccess(res.data[0].url)
                }
            }
        })
    }

    // 提交json
    fun submitReport(params: JSONObject, onSuccess: (String) -> Unit) {

        val okHttpClient = OkHttpClient()
        val requestBody: RequestBody =
            RequestBody.create(MediaType.parse("application/json"), params.toString())

        val request = Request.Builder()
            .url("${host}/api/feedback")
            .post(requestBody)
            .build()
        okHttpClient.newCall(request).enqueue(object : Callback {
            override fun onFailure(call: Call, e: IOException) {
                Log.i("shymean", "submitReport error, ${e}")
            }

            override fun onResponse(call: Call, response: Response) {
                val data = response.body()?.string()
                if (data != null) {
                    onSuccess(data)
                }
            }
        })
    }
}

一般情况下需要对响应进行json序列化,可以使用诸如gson等工具

4.2. 允许http访问

在高版本Android上使用okhttp发送http请求会报错误

java.net.UnknownServiceException: CLEARTEXT communication ** not permitted by network security policy 

这是因为 Android P 以上版本将禁止 App 使用所有未加密的连接,如果需要强制开启http,可以参考如下步骤

在res目录下新建一个xml名称的目录,然后创建network_security_config.xml文件

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
 <base-config cleartextTrafficPermitted="true" />
</network-security-config>

然后在APP的AndroidManifest.xml文件下的application标签增加以下属性


<application
 ...其他属性
 android:networkSecurityConfig="@xml/network_security_config"/>

4.3. JSON序列化

json转换成kotlin可以使用诸如json2kotlin之类的工具

首先准备需要转换的json模板,粘贴到输入框

点击生成按钮,即可获得kotlin代码

package com.laotang.train

import com.google.gson.annotations.SerializedName

data class UploadResult(

    @SerializedName("url") val url: String
)

data class UploadResponse(
    @SerializedName("code") val code: Int,
    @SerializedName("msg") val msg: String,
    @SerializedName("data") val data: List<UploadResult>
)

然后就可以将json字符串序列化成对象了

var json = response.body()?.string() // 获取json字符串
// 通过gson转换成bean
val res = Gson().fromJson(json, UploadResponse::class.java)
if (res.data[0] != null) {
    onSuccess(res.data[0].url)
}

5. 常用工具

5.1. gradle

升级as之后可能需要更新gradle,使用AS默认下载有时候会很慢,可以从网上下载后直接导入

参考:https://blog.csdn.net/u011046452/article/details/107529346

主要步骤

  • 从官网下载压缩包,如gradle-6.1.1.1-all.zip,gradle包官网地址
  • 找到.gradle下面的未完成下载的目录/Users/bcz/.gradle/wrapper/dists/gradle-6.1.1-all,里面应该有个类似于hash之类的文件夹
  • 将刚才下载的zip移动到这个hash名称文件夹,然后解压
  • 重启as即可

5.2. adb

adb学名安卓调试桥,是一个开发debug工具,有各种方便的操作

mac上述使用brew 安装

brew cask install android-platform-tools

然后就可以使用adb install直接向当前连接的手机安装本地包了

如果安装时出现Failure [INSTALL_FAILED_TEST_ONLY]错误,则需要加上-t参数

adb install -t xx.apk

有些时候,emulator卡住了,甚至无法通过活动管理器关闭,可以使用adb进行操作

adb emu kill

5.3. 打包

参考: https://blog.csdn.net/CC1991_/article/details/103285684

1、打开Android Studio,进入需要打包apk的项目工程; 2、找到Android Studio顶部菜单栏里面的Build选项,点击”Generate Signed Bundle/APK…”选项进入; 3、进入Generate Signed Bundle or APK选项,选择 jks文件路径,如果没有jks文件,可以直接在下面的Create new选项里面新建jks文件;如果已经新建有jks文件,就直接选择对应的jks文件即可。接着输入密钥密码、密钥别名、公钥密码,确认无误之后,点击Next; 4、进入选择生成apk导出的文件路径,然后选择apk的模式:release,勾选下面的V1 和 V2,二者缺一不可,选择无误之后,点击Finish按钮即可开始打包apk; 5、进过短暂的等待之后,在右下角会提示一个弹框,提示打包apk成功,那么根据第4不步选择的apk生成导出的文件夹就可以看到打包好的apk文件了。

6. 小结

作为一个前端,在学习iOS和Android开发的时候,才发现浏览器把底层功能都给封装了,web很难接触到底层编码,下面是在开发时遇见的一个关于chrome无法播放Android录像的问题

使用MediaRecorder 录制视频上传后,发现在本地播放器可以预览视频,但是在mac chrome等浏览器上面不行,Android手机浏览器可以预览,android微信浏览器也无法预览

查了一番,发现原来浏览器video标签是有编码要求的,其标准是用H.264方式编码视频的MP4文件,而我在录像的时候使用的是MediaRecorder.VideoEncoder.MPEG_4_SP编码,导致录制出来的视频编码不是h264

由于编码不正确,因此video标签无法展示对应视频,测试一下,将对应的视频用ffmpeg转码

ffmpeg -i input.mp4 -strict -2 output.mp4

之后就可以在Chrome中正常访问了,因此需要在Android录制视频结束后调整编码。

从上面这个问题可以看出了解底层开发的必要性,在此之前,作为一个写了几年的前端,我甚至不知道video标签居然是有编码问题的!!

本文主要整理了入门Android开发初期时需要接触到的概念和常见问题,能把这些问题解决了,开始上手编写项目应该是没啥问题了。

本来还想去各大培训学校官网看看Android培训大纲,发现很多学校貌似都没有相关的岗位了,难道Android开发要消失了!!!不可能的哈哈,扩宽知识面是很重要的,原生开发学起来~