Android快速入门
距离上一次写Android代码已经过去了三四年了,基本上都忘得差不多了,最近在尝试使用kotlin开发一个App,发现查询很多资料都不太全,浪费了大量的时间,因此决定整理一篇在Android入门中经常遇见的需求和问题,希望能对刚接触Android的朋友有一点帮助。
本文包含大量的参考链接,可以先看一下文章的总结,如果有疑惑再点击参考链接,否则跳转太多可能会影响学习效率。
此外还包含大量的示例代码,大部分使用kotlin编写,可以参考 koltin基础教程
本文应该会陆续更新一段时间,直至把这段时间的Android学习任务搞完。
参考
- Android入门基础,文章有点老了
- Android四大组件
UI布局
Android主流布局是xml,当然也可以直接使用代码创建view,此外目前的jetpack compose使用的声明式布局已经很流行了,未来可能是它和swift ui的天下,不过对于我们初学者,了解下xml布局也还是有点用的。
相比较之前iOS只能通过代码生成UIView的情形,安卓的xml布局已经是比较大的体验改善了。
三大基础布局
参考:https://www.jianshu.com/p/422c55f1f08e
- 线性布局,LinearLayout是在父布局中把所有的子控件按线性排列,有按行和列两种排列方式
- 相对布局,RelativeLayout布局中,子控件采用相互参照的方式确定自身的位置。可以采用相对于父控件或者其他子控件的方式,使用方式较灵活
- frame布局,FrameLayout中子控件开始全部堆砌在父视图的左上角,通过设置子控件的属性gravity来调整位置。
如果之前先有web开发经验(比如我自己),可能第一思维是使用线性布局模仿文档流,但实际上相对布局可以减少标签嵌套,需要改变一下思维
ConstraintLayout
参考
- https://developer.android.com/training/constraint-layout?hl=zh-cn ,官方文档,里面的视频演示还不错,基本上看一下就能了解大概了
- https://juejin.cn/post/6844903783294566414 ,图文教程使用约束布局创建一个登陆界面
可以使用约束在一个无嵌套的标签列表中编写复杂的UI布局,缺点是需要大概率需要借助可视化编辑器,否则手写会十分非常麻烦,同时需要记得在布局之前先把view的id给取好名称,不然后续再从各个约束依赖里面修改id,非常痛苦
动态创建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
等。
组合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"
>
自定义View
参考:https://blog.csdn.net/aigestudio/article/details/41799811,爱哥的自定义控件系列,虽然时间有点久了,仍值得阅读。
自定义view,看起来就是直接控制底层的绘制API,把无法通过系统内置组件实现的UI给绘制出来,初学先不深究。
动画效果
参考: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的动画库,初学先不深究。
Activity相关
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")
}
}
需要注意的一些细节
点击后退键返回上一页,调用onPause、
onStop和
onDestroy`,当前activity被销毁,相当于告诉系统:不需要这个activity了
点击home键,调用onPause
和onStop
,相当于告诉系统:我切出去看看其他的,稍后可能回来,因此不会调用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)
}
}
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)
使用fragment
显然,每个页面都使用Activity进行构建是可以的,但是肯定存在多个Activity复用一些布局和逻辑的场景,这时候可以使用fragment
参考
- https://developer.android.com/guide/components/fragments?hl=zh-cn 官方文档
- https://www.ituring.com.cn/book/tupubarticle/16546 android编程权威指南
- https://developer.android.com/training/basics/fragments/communicating?hl=zh-cn , fragment和activity的通信
首先需要定义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() {
// 做点事情
}
}
消息通信机制
在初学的时候经常遇见在非UI线程中操作UI然后导致App崩掉的问题,要一劳永逸地解决这个问题,需要先了解Android中的消息机制。Android有两种消息机制
- 组件间消息:Intent 机制
- 线程间消息:Message 机制
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()
}
Message:线程通信
参考
- https://mp.weixin.qq.com/s/bi42PKoLMy4uv9qEWSK-jA
- https://givemepass.blogspot.com/2015/10/thread-2_14.html
- https://blog.csdn.net/qian520ao/article/details/78262289 写的很好,自顶向下介绍了相关的概念
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的原因。
网络相关
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等工具
允许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"/>
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)
}
常用工具
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即可
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
打包
参考: 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文件了。
小结
作为一个前端,在学习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开发要消失了!!!不可能的哈哈,扩宽知识面是很重要的,原生开发学起来~
当入门了Android基础开发之后要学什么?逆向、底层、C++扩展,还有一堆东西呢
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。