都说衣不如新人不如故,技术是学新不学旧的?IPC+view+Handler+线程。

吃可爱长大的小学妹 提交于 2020-01-03 04:06:20

刚刚过去2019,新的一年2020年。都说衣不如新人不如故,技术是学新不学旧的?可是旧的知识不巩固,根基不固很容易在面试或者实战遇到很大的问题的

以下知识点PDF版后续可见

更多面试内容等等

更多完整项目下载。未完待续。源码。图文知识后续上传github。
可以点击关于我联系我获取
https://github.com/xiangjiana/Android-MS

一丶线程篇

1、线程池的好处? 四种线程池的使用场景,线程池的几个参数的理解?

参考答案
使用线程池的好处是减少在创建和销毁线程上所花的时间以及系统资源的开销,解决资源不足的问题。如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或则“过度切换”的问题,归纳总结就是

  • 重用存在的线程,减少对象创建、消亡的开销,性能佳。
  • 可有效控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞
  • 提供定时执行、定期执行、单线程、并发数控制等功能。

Android 中的线程池都是直接或间接通过配置
ThreadPoolExecutor 来实现不同特性的线程池.Android 中最常见的类具有不同特性的线程池分别为:

  • newCachedThreadPool 只有非核心线程,最大线程数非常大,所有线程都活动时会为新任务创建新线程,否则会利用空闲线程 ( 60s 空闲时间,过了就会被回收,所以线程池中有 0 个线程的可能 )来处理任务.
    优点: 任何任务都会被立即执行(任务队列SynchronousQuue 相当于一个空集合);比较适合执行大量的耗时较少的任务.

  • newFixedThreadPool 只有核心线程,并且数量固定的,所有线程都活动时,因为队列没有限制大小,新任务会等待执行,当线程池空闲时不会释放工作线程,还会占用一定的系统资源。
    优点: 更快的响应外界请求

  • newScheduledThreadPool 核心线程数固定,非核心线程(闲着没活干会被立即回收数)没有限制.
    优点: 执行定时任务以及有固定周期的重复任务

  • newSingleThreadExecutor 只有一个核心线程,确保所有的任务都在同一线程中按序完成
    优点: 不需要处理线程同步的问题

通过源码可以了解到上面的四种线程池实际上还是利用ThreadPoolExecutor 类实现的

  //详细介绍课参考Executors.java类
  public static ExecutorService newCachedThreadpool () {
         return new ThreadPoolExecutor (0,Integer.MAX_VALUE
                                        60L,TimeUnit.SECONDS,
                                        new SynchronousQueue<Runnable>());
  }
  ThreadPoolExecutor(int corepoolSize,int maxmumpoolSize,
                     long keepAliveTime,TimeUnit unit,
                     Blockingqueue<Runnable>workqueue,RejectedExecutionHandler handler
2、Android 中还了解哪些方便线程切换的类?

参考回答:

  • AsyncTask 底层封装了线程池和 Handler,便于执行后台任务以及在子线程中进行 UI 操作。
  • HandlerThread 一种具有消息循环的线程,其内部可使用Handler。
  • IntentService 是一种异步、会自动停止的服务,内部采用HandlerThread
3、 AsyncTask 的原理

参考回答:

  • AsyncTask 中有两个线程池(SerialExecutor 和THREAD_POOL_EXECUTOR)和一个 Handler(InternalHandler),其中线程池 SerialExecutor 用于任务的排队,而线程池THREAD_POOL_EXECUTOR 用于真正地执行任务,InternalHandler 用于将执行环境从线程池切换到主线程。
  • 静态的 Handler 对象,为了能够将执行环境切换到主线程,这就要求 这个对象必须在主线程创建。由于静态成员会在加载类的时候进行初始化,因此这就变相要求 AsyncTask 的类必须在主线程中加载,否则同一个进程中的 AsyncTask 都将无法正常工作。
4、IntentService 有什么用 ?

IntentService 可用于执行后台耗时的任务,当任务执行完成后会自动停止,同时由于 IntentService 是服务的原因,不同于普通 Service,IntentService 可自动创建子线程来执行任务,这导致它的优先级比单纯的线程要高,不容易被系统杀死,所以IntentService 比较适合执行一些高优先级的后台任务。

5、直接在 Activity 中创建一个 thread 跟在 service 中创建一个 thread 之间的区别?

参考回答:

  • 在 Activity 中被创建: 该 Thread 的就是为这个 Activity 服务的,完成这个特定的 Activity 交代的任务,主动通知该 Activity一些消息和事件,Activity 销毁后,该 Thread 也没有存活的意义了
  • 在 Service 中被创建: 这是保证最长生命周期的 Thread 的唯一方式,只要整个 Service 不退出,Thread 就可以一直在后台执行,一般在 Service 的 onCreate()中创建,在 onDestroy()中销毁。所以,在 Service 中创建的 Thread,适合长期执行一些独立于 APP 的后台任务,比较常见的就是:在 Service 中保持与服务器端的长连接。
6、ThreadPoolExecutor 的工作策略 ?

参考回答: ThreadPoolExecutor 执行任务时会遵循如下规则

  • 如果线程池中的线程数量未达到核心线程的数量,那么会直接启动一个核心线程来执行任务。
  • 如果线程池中的线程数量已经达到或则超过核心线程的数量,那么任务会被插入任务队列中排队等待执行。
  • 如果在第 2 点无法将任务插入到任务队列中,这往往是由于任务队列已满,这个时候如果在线程数量未达到线程池规定的最大值,那么会立刻启动一个非核心线程来执行任务。
  • 如果第 3 点中线程数量已经达到线程池规定的最大值,那么就拒绝执行此任务,ThreadPoolExecutor 会调用RejectedExecutionHandlerrejectedExecution 方法来通知调用者。
7、Handler、Thread 和 HandlerThread 的差别?

参考回答:

  • Handler: 在 android 中负责发送和处理消息,通过它可以实现其他支线线程与主线程之间的消息通讯。
  • Thread: Java 进程中执行运算的最小单位,亦即执行处理机调度的基本单位。某一进程中一路单独运行的程序。
  • HandlerThread: 一个继承自 Thread 的类 HandlerThread,Android 中没有对 Java 中的 Thread 进行任何封装,而是提供了一个继承自 Thread 的类 HandlerThread 类,这个类对 Java的 Thread 做了很多便利的封装。HandlerThread 继承于Thread,所以它本质就是个 Thread。与普通 Thread 的差别就在于,它在内部直接实现了 Looper 的实现,这是 Handler 消息机制必不可少的。有了自己的 looper,可以让我们在自己的线程中分发和处理消息。如果不用 HandlerThread 的话,需要手动去调用 Looper.prepare()Looper.loop()这些方法。
8、ThreadLocal 的原理

参考回答:
ThreadLocal 是一个关于创建线程局部变量的类。使用场景如下所示:

  • 实现单个线程单例以及单个线程上下文信息存储,比如交易 id 等
  • 实现线程安全,非线程安全的对象使用 ThreadLocal 之后就会变得线程安全,因为每个线程都会有一个对应的实例。 承载一些线程相关的数据,避免在方法中来回传递参数。

当需要使用多线程时,有个变量恰巧不需要共享,此时就不必使用 synchronized 这么麻烦的关键字来锁住,每个线程都相当于在堆内存中开辟一个空间,线程中带有对共享变量的缓冲区,通过缓冲区将堆内存中的共享变量进行读取和操作,ThreadLocal 相当于线程内的内存,一个局部变量。每次可以对线程自身的数据读取和操作,并不需要通过缓冲区与 主内存中的变量进行交互。并不会像 synchronized 那样修改主内存的数据,再将主内存的数据复制到线程内的工作内存。ThreadLocal 可以让线程独占资源,存储于线程内部,避免线程堵塞造成 CPU 吞吐下降。

在每个 Thread 中包含一个 ThreadLocalMapThreadLocalMap 的 key 是 ThreadLocal 的对象,value 是独享数据。

9、多线程是否一定会高效(优缺点)

参考回答:
多线程的优点:

  • 方便高效的内存共享 - 多进程下内存共享比较不便,且会抵消掉多进程编程的好处
  • 较轻的上下文切换开销 - 不用切换地址空间,不用更改CR3 寄存器,不清空 TLB
  • 线程上的任务执行完后自动销毁

多线程的缺点:

  • 开启线程需要占用一定的内存空间(默认情况下,每一个线程都占 512KB)
  • 如果开启大量的线程,会占用大量的内存空间,降低程序的性能
  • 线程越多,cpu 在调用线程上的开销就越大
  • 程序设计更加复杂,比如线程间的通信、多线程的数据共享

综上得出,多线程不一定能提高效率,在内存空间紧张的情况下反而是一种负担,因此在日常开发中,应尽量

  • 不要频繁创建,销毁线程,使用线程池
  • 减少线程间同步和通信(最为关键)
  • 避免需要频繁共享写的数据
  • 合理安排共享数据结构,避免伪共享(false sharing)
  • 使用非阻塞数据结构/算法
  • 避免可能产生可伸缩性问题的系统调用(比如 mmap)
  • 避免产生大量缺页异常,尽量使用 Huge Page
  • 可以的话使用用户态轻量级线程代替内核线程
10、多线程中,让你做一个单例,你会怎么做

参考回答:

  • 多线程中建立单例模式考虑的因素有很多,比如线程安全 -延迟加载-代码安全:如防止序列化攻击,防止反射攻击(防止反射进行私有方法调用) -性能因素
  • 实现方法有多种,饿汉,懒汉(线程安全,线程非安全),双重检查(DCL),内部类,以及枚举
  //OkHttp例子
  private static volatile OkHttpHelper SInstance;
  
  public static OkHttpHelper getInstance() {
         if (sInstance =null) {
             synchronized (OkHttpHelper.class) {
                if (sInstance =null) {
                    sInstance = new OkHttpHelper();
                }
             }
          }
     return sInstance;
  }
11、除了 notify 还有什么方式可以唤醒线程

参考回答:

  • 当一个拥有 Object 锁的线程调用 wait()方法时,就会使当前线程加入 object.wait 等待队列中,并且释放当前占用的 Object锁,这样其他线程就有机会获取这个 Object 锁,获得 Object锁的线程调用 notify()方法,就能在 Object.wait 等待队列中随机唤醒一个线程(该唤醒是随机的与加入的顺序无关,优先级高的被唤醒概率会高)
  • 如果调用 notifyAll()方法就唤醒全部的线程。注意:调用notify()方法后并不会立即释放 object 锁,会等待该线程执行完毕后释放 Object 锁。
12、什么是 ANR ? 什么情况会出现 ANR ?如何避免 ? 在不看代码的情况下如何快速定位出现 ANR 问题所在 ?

参考回答:

  • ANR(Application Not Responding,应用无响应):当操作在一段时间内系统无法处理时,会在系统层面会弹出 ANR 对话框
  • 产生 ANR 可能是因为 5s 内无响应用户输入事件、10s 内未结束BroadcastReceiver、20s 内未结束 Service
  • 想要避免 ANR 就不要在主线程做耗时操作,而是通过开子线程,方法比如继承 Thread 或实现 Runnable 接口、使用AsyncTask IntentServiceHandlerThread

二丶Handler篇

1、谈谈消息机制 Handler 作用 ?有哪些要素 ?流程是怎样的 ?

参考回答:
负责跨线程通信,这是因为在主线程不能做耗时操作,而子线程不能更新 UI,所以当子线程中进行耗时操作后需要更新 UI时,通过 Handler 将有关 UI 的操作切换到主线程中执行。

具体分为四大要素

  • Message(消息): 需要被传递的消息,消息分为硬件产生的消息(如按钮、触摸)和软件生成的消息。
  • MessageQueue(消息队列): 负责消息的存储与管理,负责管理由 Handler 发送过来的 Message。读取会自动删除消息,单链表维护,插入和删除上有优势。在其 next()方法中会无限循环,不断判断是否有消息,有就返回这条消息并移除。
  • Handler(消息处理器): 负责 Message 的发送及处理。主要向消息池发送各种消息事件(Handler.sendMessage())和处理相应消息事件(Handler.handleMessage()),按照先进先出执行,内部使用的是单链表的结构。
  • Looper(消息池): 负责关联线程以及消息的分发,在该线程下从 MessageQueue 获取 Message,分发给Handler,Looper 创建的时候会创建一个MessageQueue,调用 loop()方法的时候消息循环开
    始,其中会不断调用 messageQueuenext()方法,当有消息就处理,否则阻塞在 messageQueuenext()方法中。当 Looperquit()被调用的时候会调用messageQueuequit(),此时 next()会返回 null,然后 loop()方法也就跟着退出。

流程:

  • 在主线程创建的时候会创建一个 Looper,同时也会在在Looper 内部创建一个消息队列。而在创键 Handler 的时候取出当前线程的 Looper,并通过该 Looper 对象获得消息队列,然后 Handler 在子线程中通过MessageQueue.enqueueMessage 在消息队列中添加一条 Message。
  • 通过 Looper.loop() 开启消息循环不断轮询调用MessageQueue.next(),取得对应的 Message 并且通过 Handler.dispatchMessage 传递给 Handler,最终调用 Handler.handlerMessage 处理消息。
2、一个线程能否创建多个 Handler,Handler 跟 Looper 之间的对应关系 ?

参考回答:

  • 一个 Thread 只能有一个 Looper,一个 MessageQueen,可以有多个 Handler
  • 以一个线程为基准,他们的数量级关系是: Thread(1) :Looper(1) : MessageQueue(1) : Handler(N)
3、软引用跟弱引用的区别

参考回答:

  • 软引用(SoftReference): 如果一个对象只具有软引用,则内存空间充足时,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以一直被程序使用。
  • 弱引用(WeakReference): 如果一个对象只具有弱引用,那么在垃圾回收器线程扫描的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
  • 两者之间根本区别在于: 只具有弱引用的对象拥有更短暂的生命周期,可能随时被回收。而只具有软引用的对象只有当内存不够的时候才被回收,在内存足够的时候,通常不被回收。
4、Handler 引起的内存泄露原因以及最佳解决方案

参考回答:
**泄露原因:**Handler 允许我们发送延时消息,如果在延时期间用户关闭了 Activity,那么该 Activity 会泄露。 这个泄露是因为 Message 会持有 Handler,而又因为 Java 的特性,内部类会持有外部类,使得 Activity 会被Handler 持有,这样最终就导致 Activity 泄露。

**解决方案:**将 Handler 定义成静态的内部类,在内部持有Activity 的弱引用,并在 AcitivityonDestroy()中调用 handler.removeCallbacksAndMessages(null)及时移除所有消息。

5、为什么系统不建议在子线程访问 UI?

参考回答:
Android 的 UI 控件不是线程安全的,如果在多线程中并发访问可能会导致 UI 控件处于不可预期的状态

这时你可能会问为何系统不对 UI 控件的访问加上锁机制呢?因为

  • 加锁机制会让 UI 访问逻辑变的复杂
  • 加锁机制会降低 UI 的访问效率,因为加锁会阻塞某些线程的执行
6、Looper 死循环为什么不会导致应用卡死?

参考回答:

  • 主线程的主要方法就是消息循环,一旦退出消息循环,那么你的应用也就退出了,Looer.loop()方法可能会引起主线程的阻塞,但只要它的消息循环没有被阻塞,能一直处理事件就不会产生 ANR 异常。
  • 造成 ANR 的不是主线程阻塞,而是主线程的 Looper 消息处理过程发生了任务阻塞,无法响应手势操作,不能及时刷新 UI。
  • 阻塞与程序无响应没有必然关系,虽然主线程在没有消息可处理的时候是阻塞的,但是只要保证有消息的时候能够立刻处理,程序是不会无响应的。
7、使用 Handler 的 postDealy 后消息队列会有什么变化?

参考回答:
如果队列中只有这个消息,那么消息不会被发送,而是计算到时唤醒的时间,先将 Looper 阻塞,到时间就唤醒它。但如果此时要加入新消息,该消息队列的对头跟 delay 时间相比更长,则插入到头部,按照触发时间进行排序,队头的时间最小、队尾的时间最大

8、可以在子线程直接 new 一个 Handler 吗?怎么做?

参考回答:
不可以,因为在主线程中,Activity 内部包含一个 Looper 对象,它会自动管理 Looper,处理子线程中发送过来的消息。而对于子线程而言,没有任何对象帮助我们维护 Looper 对象,所以需要我们自己手动维护。所以要在子线程开启 Handler 要先创建 Looper,并开启 Looper 循环

  //代码示例
  new Thread(new Runnable() {
             @Override
             public void run() {
                looper.prepare();
                new Handler() {
                    @Override
                     public void handlerMessage(Message msg) {
                        super.handleMessage(msg);
                     }
                     looper.loop();
                }
     }).start();
9、Message 可以如何创建?哪种效果更好,为什么?

参考回答: 可以通过三种方法创建:

  • 直接生成实例 Message m = new Message
  • 通过 Message m = Message.obtain
  • 通过 Message m = mHandler.obtainMessage()

后两者效果更好,因为 Android 默认的消息池中消息数量是 10,而后
两者是直接在消息池中取出一个 Message 实例,这样做就可以避免多
生成 Message 实例。

三丶IPC

1 丶Android 中进程和线程的关系和区别?

参考回答:

  • 线程是 CPU 调度的 最小单元,同时线程是一种 有限的系统资源
  • 进程一般指一个执行单元,在 PC 和移动设备上一个程序或则一个应用
  • 一般来说,一个 App 程序 至少有一个进程,一个进程 至少有一个线程(包含与被包含的关系), 通俗来讲就是,在App 这个工厂里面有一个进程,线程就是里面的生产线,但主线程(主生产线)只有一条,而子线程(副生产线)可以有多个
  • 进程有自己独立的地址空间,而进程中的线程共享此地址空间,都可以 并发执行
2 、如何开启多进程 ? 应用是否可以开启 N 个进程 ?

参考回答:

  • AndroidMenifest 中给四大组件指定属性android:process 开启多进程模式
  • 在内存允许的条件下可以开启 N 个进程
3 、为何需要 IPC ?多进程通信可能会出现的问题?

参考回答:
所有运行在不同进程的四大组件(Activity、Service、Receiver丶ContentProvider)共享数据都会失败,这是由于 Android 为每个应用分配了独立的虚拟机,不同的虚拟机在内存分配上有不同的地址空间,这会导致在不同的虚拟机中访问同一个类的对象会产生多份副本。比如常用例子( 通过开启多进程获取更大内存空间、两个或则多个应用之间共享数据、微信全家桶)

一般来说,使用多进程通信会造成如下几方面的问题

  • 静态成员和单例模式完全失效: 独立的虚拟机造成
  • 线程同步机制完全实效: 独立的虚拟机造成
  • SharedPreferences的可靠性下降: 这是因为 Sp 不支持两个进程并发进行读写,有一定几率导致数据丢失
  • Application 会多次创建: Android 系统在创建新的进程会分配独立的虚拟机,所以这个过程其实就是启动一个应用的过程,自然也会创建新的Application
4 丶Android 中 IPC 方式、各种方式优缺点,为什么选择 Binder

参考回答:

与 Linux 上传统的 IPC 机制,比如 System V,Socket 相比,Binder 好在哪呢?
传输效率高、可操作性强: 传输效率主要影响因素是内存拷贝的次数,拷贝次数越少,传输速率越高。从 Android进程架构角度分析:对于消息队列、Socket 和管道来说,数据先从发送方的缓存区拷贝到内核开辟的缓存区中,再从内核缓存区拷贝到接收方的缓存区,一共两次拷贝,如图:

而对于 Binder 来说,数据从发送方的缓存区拷贝到内核的缓存区,而接收方的缓存区与内核的缓存区是映射到同一块物理地址的,节省了一次数据拷贝的过程,如图:

由于共享内存操作复杂,综合来看,Binder 的传输效率是最好的。

实现 C/S 架构方便: Linux 的众 IPC 方式除了 Socket 以外都不是基于 C/S 架构,而 Socket 主要用于网络间的通信且传输效率较低。Binder 基于 C/S 架构 ,Server 端与Client 端相对独立,稳定性较好。
安全性高: 传统 Linux IPC 的接收方无法获得对方进程可靠的 UID/PID,从而无法鉴别对方身份;而 Binder 机制为每个进程分配了 UID/PID 且在 Binder 通信时会根据UID/PID 进行有效性检测。

5 、Binder机制的左右和原理

参考回答:
Linux 系统将一个进程分为 用户空间和 内核空间。对于进程之间来说,用户空间的数据不可共享,内核空间的数据可共享,为了保证安全性和独立性,一个进程不能直接操作或者访问另一个进程,即 Android 的进程是相互独立、隔离的,这就需要跨进程之间的数据通信方式

一次完整的 Binder IPC 通信过程通常是这样:

  • 首先 Binder 驱动在内核空间创建一个数据接收缓存区;
  • 接着在内核空间开辟一块内核缓存区,建立内核缓存区和内核中数据接收缓存区之间的映射关系,以及内核中数据接收缓存区和接收进程用户空间地址的映射关系;
  • 发送方进程通过系统调用 copyfromuser() 将数据 copy 到内核中的内核缓存区,由于内核缓存区和接收进程的用户空间存在内存映射,因此也就相当于把数据发送到了接收进程的用户空间,这样便完成了一次进程间的通信。
6 、Binder 框架中 ServiceManager的作用

参考回答:

  • Binder 框架 是基于 C/S 架构的。由一系列的组件组成,包括 Client、Server、ServiceManager、Binder 驱动,其中Client、Server、Service Manager 运行在用户空间,Binder 驱动运行在内核空间
  • Server&Client: 服务器&客户端。在 Binder 驱动和Service Manager 提供的基础设施上,进行 Client-Server 之间的通信。
  • ServiceManager(如同 DNS 域名服务器)服务的管理者,将 Binder 名字转换为 Client 中对该 Binder的引用,使得 Client 可以通过 Binder 名字获得Server 中 Binder 实体的引用。
  • Binder 驱动(如同路由器): 负责进程之间 binder通信的建立,传递,计数管理以及数据的传递交互等底层支持
7丶Bundle 传递对象为什么需要序列化?Serialzable 和 Parcelable

参考回答:

  • 因为 bundle 传递数据时只支持基本数据类型,所以在传递对象时需要序列化转换成可存储或可传输的本质状态(字节流)。序列化后的对象可以在网络、IPC(比如启动另一个进程的 Activity、Service 和 Reciver)之间进行传输,也可以存储到本地。
  • 序列化实现的两种方式:实现 Serializable/Parcelable接口。不同点如图:
8 、讲讲 AIDL ?原理是什么?如何优化多模块都使用 AIDL

参考回答:
AIDL(Android Interface Definition Language,Android接口定义语言):如果在一个进程中要调用另一个进程中对象的方法,可使用 AIDL 生成可序列化的参数,AIDL 会生成一个服务端对象的代理类,通过它客户端实现间接调用服务端对象的方法

AIDL 的本质是系统提供了一套可快速实现 Binder 的工具。关键类和方法:

  • AIDL 接口: 继承 Interface
  • Stub 类: Binder 的实现类,服务端通过这个类来提供服务。
  • Proxy 类: 服务器的本地代理,客户端通过这个类调用服务器的方法。
  • asInterface() 客户端调用,将服务端的返回的Binder 对象,转换成客户端所需要的 AIDL 接口类型对象。如果客户端和服务端位于统一进程,则直接返回 Stub 对象本身,否则返回系统封装后的Stub.proxy 对象
  • **asBinder():**根据当前调用情况返回代理 Proxy 的Binder 对象。
  • onTransact() 运行服务端的 Binder 线程池中,当客户端发起跨进程请求时,远程请求会通过系统底层封装后交由此方法来处理。
  • transact() 运行在客户端,当客户端发起远程请求的同时将当前线程挂起。之后调用服务端的onTransact()直到远程请求返回,当前线程才继续执行。

当有多个业务模块都需要 AIDL 来进行 IPC,此时需要为每个模块创建特定的 aidl 文件,那么相应的 Service 就会很多。必然会出现系统资源耗费严重、应用过度重量级的问题。解决办法是建立 Binder 连接池,即将每个业务模块的Binder 请求统一转发到一个远程 Service 中去执行,从而避免重复创建 Service。

工作原理: 每个业务模块创建自己的 AIDL 接口并实现此接口,然后向服务端提供自己的唯一标识和其对应的 Binder 对象。服务端只需要一个 Service,服务器提供一个 queryBinder 接口,它会根据业务模块的特征来返回相应的 Binder 对象,不同的业务模块拿到所需的 Binder 对象后就可进行远程方法的调用了

更多完整项目下载。未完待续。源码。图文知识后续上传github。
可以点击关于我联系我获取
https://github.com/xiangjiana/Android-MS

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!