Android面试知识点总结(二)——进阶篇

478 阅读20分钟

上一篇:Android面试知识点总结(一)—— 基础篇

性能优化(内存泄漏,GC)

内存泄漏

参考:Android性能优化

  • 内存溢出:程序使用的空间大于原本系统给它申请的空间。
  • 内存泄漏:在new了对象之后,没有使用这个对象了,但是又没有被回收,一直占用着内存。
  • 场景:
    1. 集合类
    2. Static关键字修饰的成员变量
    3. 非静态内部类 / 匿名类
    4. 资源对象使用后未关闭

ANR、OOM处理办法

ANR

参考:Android性能优化(七)之你真的理解ANR吗

  • ANR产生的原因:

    • 应用自身引起,例如:主线程阻塞、IO挂起/等待等;
    • 其他进程间接引起,例如:
      • 当前应用进程进行进程间通信请求其他进程,其他进程的操作长时间没有反馈;

      • 其他进程的CPU占用率高,使得当前应用进程无法抢占到CPU时间片;

    当发生ANR的时候Logcat中会出现 I/art相关的打印

    ANR的Log信息保存在:/data/anr/traces.txt,每一次新的ANR发生,会把之前的ANR信息覆盖掉

  • ANR触发场景

    1. InputDispatching Timeout :输入事件分发超时5s未响应完毕;
    2. BroadcastQueue Timeout :前台广播在10s内、后台广播在20秒内未执行完成;
    3. Service Timeout :前台服务在20s内、后台服务在200秒内未执行完成;
    4. ContentProvider Timeout :内容提供者,在publish过超时10s;

OOM

当堆内存(Heap Space)没有足够空间存放新创建的对象时,就会抛出 OutOfMemoryError 错误

Android中的OOM,大部分是Bitmap造成的

  • 解决方案:
  1. 拆:将一个占用比较大的资源拆解成多次进行加载/使用,减少一次过度的内存造成OOM的问题
  2. 开:新增额外的进程,获取额外的内存控件
  3. 节:降低相关资源的内存占用质量,避免过度的消耗内存造成OOM
  4. 存:将内存中一些占用并未使用的资源进行本地磁盘的缓存和回收释放(回收算法可参考LRU),开辟更多的额外内存空间出来

Jvm GC机制 可达性分析与GC Roots

参考:聊聊java的GC机制

Java中通过可达性分析法来确定某个对象是不是“垃圾”

  1. 该方法的基本思想是通过一系列的“GC Roots”对象作为起点进行搜索,如果在“GC Roots”和一个对象之间没有可达路径,则称该对象是不可达的

    不过要注意的是被判定为不可达的对象不一定就会成为可回收对象

  2. 被判定为不可达的对象要成为可回收对象必须至少经历两次标记过程,如果在这两次标记过程中仍然没有逃脱成为可回收对象的可能性,则基本上就真的成为可回收对象了

    注意其本质是通过找出所有活对象来把其余空间认定为“无用”,而不是找出所有死掉的对象并回收它们占用的空间

Activity A 启动另一个Activity B

Activity A 的onPause() → Activity B的onCreate() → onStart() → onResume() → Activity A的onStop();如果B是透明主题又或则是个DialogActivity,则不会回调A的onStop

Dialog为什么在Application上不能展示

采用 Application 的 Context 就会报错,是因为应用 token 所导致,应用 token 一般只有 Activity 拥有。系统 Window 比较特殊,不需要 token

Activity、Window和View三者间的关系?

参考:Activity、Window、View三者关系

  • Activity就像一个控制器,统筹视图的添加与显示,以及通过其他回调方法,来与Window、以及View进行交互

  • Window:是所有View的直接管理者,任何视图都通过Window呈现

    Window->DecorView->View ; Activity的setContentView底层通过Window完成

    • Window是一个抽象类,具体实现是PhoneWindow。PhoneWindow中有个内部类DecorView,通过创建DecorView来加载Activity中设置的布局XML文件

    • 创建Window需要通过WindowManager创建,通过WindowManager将DecorView加载其中,并将DecorView交给ViewRoot,进行视图绘制以及其他交互

      1. WindowManager是外界访问Window的入口

      2. Window具体实现位于WindowManagerService中

      3. WindowManager和WindowManagerService的交互是通过IPC完成

      WindowManager的主要功能是什么?

      • 添加、更新、删除View
  • DecorView:DecorView是FrameLayout的子类,它可以被认为是Android视图树的根节点视图

    • DecorView作为顶级View,一般情况下它内部包含一个竖直方向的LinearLayout,一般情况下这个LinearLayout里面有上下三个部分,上面是个ViewStub(状态栏),中间的是标题栏FrameLayout,下面的是内容栏FrameLayout

    • 在Activity中通过setContentView所设置的布局文件其实就是被加到内容栏之中的,成为其唯一子View

  • 总结:Activity负责统筹视图的添加与显示的平台,View是视图,Window负责管理和呈现所有的视图

Activity 创建时通过 attach()初始化了一个 Window 也就是 PhoneWindow, 一个 PhoneWindow 持有一个 DecorView 的实例,DecorView 本身是一个 FrameLayout,继承于 View,Activty 通过 setContentView 将 xml 布局控件 不断 addView()添加到 View 中,最终显示到 Window 于我们交互

子线程为什么不能更新UI?

参考:这次彻底搞明白子线程到底能不能更新 UI

参考:Android子线程也能更新UI?

参考:Android 子线程更新UI了解吗?

注意:UI 只能在主线程中进行,是因为UI控件线程不安全,多线程并发会导致UI控件处于不可预期的状态(不加锁原因:1. 加锁会变复杂 2.降低访问效率)ViewRootImpl 对UI操作做了验证,如果子线程访问ui会抛出异常

//ViewRootImpl
void checkThread() {
   if (mThread != Thread.currentThread()) {
       throw new CalledFromWrongThreadException(
               "Only the original thread that created a view hierarchy can touch its views.");
   }
}

常用的中requestLayout() 和 未开启硬件加速的invalidate()会触发checkThread()

  • ViewRootImpl在Activity生命周期handleResumeActivity(OnResume)之前是还没有进行创建的,所以不会去执行checkThread方法,在调用onResumeViewRootImpl被创建后,会去测量UI的大小调用requestLayout,该方法中会触发checkThread,对所在线程进行判断

APP启动流程

  1. 启动App进程

    点击Launcher桌面App图标后,Launcher程序会调用startActivity()函数,通过Binder通信发送给system_service进程,在system_service进程中,由AMS通过socket通信告知Zygote进程fork一个子进程(App进程)

  2. 开启App主线程

    App进程启动后,会实例化一个ActivityThread,并执行其main函数,同时会创建ApplicationThread、Looper、Handler对象,并开启主线程消息循环Looper.loop()

  3. 创建并出书画Application和Activity

    ActivityThread会通过调用attach方法进行Binder通信,通知system_service进程执行attachApplication方法。在attachApplication方法中,AMS分别通过bindApplication、scheduleLaunchActivity通知App进程主线程Handler,对App进程的Application和Activityj进行初始化,并执行Application、Activity的生命周期

  4. UI布局和绘制

    主线程handler初始化Activity时,会执行创建PhoneWindow、初始化DecorView的操作,并添加布局到DecorView的ContentView中。ContentView,对应着Activity的setContentView中设置的layout.xml布局文件所在的最外层父布局

    App启动过程

Application生命周期

  1. onCreate() 创建时执行
  2. onTerminate() 程序终止时执行
  3. onLowMemory() 低内存时执行
  4. onConfigurationChanged(Configuration newConfig) 配置改变时触发这个方法
  5. onTrimMemory(int level) 程序在进行内存清理时执行

Parcelable 与 Serializable 对比

  • Serializable 使用 I/O 读写存储在硬盘上,而 Parcelable 是直接在内存中读写
  • Serializable 会使用反射,序列化和反序列化过程需要大量 I/O 操作, Parcelable 自已实现封送和解封(marshalled &unmarshalled)操作不需要用反射,数据也存放在 Native 内存中,效率要快很多

Kotlin协程

协程是一种并发设计模式,子任务协作运行,处理异步问题的思想;协程实际上就是极大程度的复用线程,通过让线程满载运行,达到最大程度的利用CPU,进而提升应用性能

关键字:suspend::代码执行到 suspend 函数的时候会挂起,并且这个挂起是非阻塞式的,它不会阻塞你当前的线程

注意:所有协程都需要在作用域中启动

特点

  1. 轻量:您可以在单个线程上运行多个协程,因为协程支持挂起,不会使正在运行协程的线程阻塞。挂起比阻塞节省内存,且支持多个并行操作。
  2. 内存泄漏更少:使用结构化并发机制在一个作用域内执行多项操作。
  3. 内置取消支持:取消操作会自动在运行中的整个协程层次结构内传播。
  4. Jetpack 集成:许多 Jetpack 库都包含提供全面协程支持的扩展。某些库还提供自己的协程作用域,可供您用于结构化并发。
Dispatchers.Main适合执行不耗时不进行阻塞的任务,运行在主线程
Dispatchers.IO适合 IO 密集型的任务,比如:读写文件,操作数据库以及网络请求
Dispatchers.Default针对 CPU 密集型工作进行了优化,比如计算/JSON解析等

runBlocking 顶层函数

不主动切换到子线程,只有等runBlocking中流程执行完成才会执行后续代码,会阻塞当前线程

  • 通常适用于单元测试的场景,而业务开发中不会用到这种方法,因为它是线程阻塞的。
  runBlocking {
  	//执行逻辑,此处不会进行线程的切换,在原有线程上执行
  }

launch

会主动开辟一个子线程,去执行相关任务,完成异步操作

//必须在作用域中进行
launch {
	//执行逻辑,会进行线程的切换,切换到子线程上进行执行
}

async

会主动开辟子线程执行任务,实现异步操作,与**launch不同点在于async存在返回值,可以通过await**方法进行获取

//必须在作用域中进行
//方式一
async {
  //执行逻辑,会进行线程的切换,切换到子线程上进行执行
}

//方式二, 可以获取返回值
val result = async {
   //执行逻辑,会进行线程的切换,切换到子线程上进行执行
  //return@async
}.await
//注意:当调用await方法时,await会阻塞当前协程,直到获取到结果

withContext 挂起函数(挂起函数必须在另一个挂起函数或者协程作用域中执行)

withContext函数可以切换到指定的线程,并在闭包内的逻辑执行结束之后,自动把线程切回去继续执行

private fun asyncDemo() {
  //原方式
		Thread {
      //处理请求等耗时任务
      runOnUiThread {
        //切换到主线程
      }
    }
  
  //协程withContext方式
	CoroutineScope(Job()).launch(Dispatchers.Main) {
  		val result = withContext(Diapatchers.IO) {
        //执行耗时任务
        //最后一行return结果
      }
    	//UI线程更新result数据
	}
}

CoroutineScope

可以通过 context 参数去管理和控制协程的生命周期(这里的 context 和 Android 里的不是一个东西,是一个更通用的概念)

  • CoroutineContext

    1. Job:控制协程的生命周期。

    2. CoroutineDispatcher:将工作分派到适当的线程。

    3. CoroutineName:协程的名称,可用于调试。

    4. CoroutineExceptionHandler:处理未捕获的异常。

      val ctxHandler = CoroutineExceptionHandler {context , exception ->
      }
      val context = Job() + Dispatchers.IO + EmptyCoroutineContext + ctxHandler
      CoroutineScope(context).launch {
        get(url)
      }
      
      suspend fun get(url: String) {
        //请求
      }
      

Flow

参考:Flow与LiveData使用区别

为什么要用Flow

LiveData、Kotlin Flow、RxJava 三者都属于可观察的数据容器类

  • LiveData

    1. LiveData 只能在主线程更新数据,只能在主线程 setValue,即使 postValue 内部也是切换到主线程执行;

    2. LiveData 数据重放问题(粘性): 注册新的订阅者,会重新收到 LiveData 存储的数据

    3. LiveData 不防抖: 重复 setValue 相同的值,订阅者会收到多次 onChanged() 回调。

      可以使用 distinctUntilChanged 解决

    4. LiveData 不支持背压: 在数据生产速度 > 数据消费速度时,LiveData 无法正常处理。比如在子线程大量 postValue 数据但主线程消费跟不上时,中间就会有一部分数据被忽略

  • Flow

    1. Flow 支持协程:Flow 基于协程基础能力,能够以结构化并发的方式生产和消费数据,能够实现线程切换(依靠协程的 Dispatcher)
    2. Flow 支持背压: Flow 的子类 SharedFlow 支持配置缓存容量,可以应对数据生产速度 > 数据消费速度的情况
    3. Flow 支持数据重放配置: Flow 的子类 SharedFlow 支持配置重放 replay,能够自定义对新订阅者重放数据的配置
    4. Flow 相对 RxJava 的学习门槛更低: Flow 的功能更精简,学习性价比相对更高。不过 Flow 是基于协程,在协程会有一些学习成本
    5. Flow 不是生命周期感知型组件: Flow 不是 Android 生态下的产物,自然 Flow 是不会关心组件生命周期

冷数据流和热数据流

  • 普通 Flow(冷流): 冷流是不共享的,也没有缓存机制。冷流只有在订阅者 collect 数据时,才按需执行发射数据流的代码。冷流和订阅者是一对一的关系,多个订阅者间的数据流是相互独立的,一旦订阅者停止监听或者生产代码结束,数据流就自动关闭。

    数据源会延迟到消费者开始监听时才生产数据(如终端操作 collect{}),并且每次订阅都会创建一个全新的数据流。 一旦消费者停止监听或者生产者代码结束,Flow 会自动关闭。

    使用 Flow.shareIn 或 Flow.stateIn 可以把冷流转换为热流,一来可以将数据共享给多个订阅者,二来可以增加缓冲机制

  • SharedFlow / StateFlow(热流): 热流是共享的,有缓存机制的。无论是否有订阅者 collect 数据,都可以生产数据并且缓存起来。热流和订阅者是一对多的关系,多个订阅者可以共享同一个数据流。当一个订阅者停止监听时,数据流不会自动关闭

    它们都有一个可变的版本 MutableSharedFlow 和 MutableStateFlow,这与 LiveData 和 MutableLiveData 类似,对外暴露接口时,应该使用不可变的版本

安全的观察Flow数据流

Flow 不具备 LiveData 的生命周期感知能力,所以订阅者在监听 Flow 数据流时,会存在生命周期安全的问题。Google 推荐的做法是使用 Lifecycle#repeatOnLifecycle API,在生命周期到达指定状态时,自动创建并启动协程执行代码块,在生命周期低于该状态时,自动取消协程。因为 repeatOnLifecycle 不是挂起函数,所以不遵循结构化并发的规则。

如果不使用 Lifecycle#repeatOnLifecycle API,具体会出现什么问题呢?

  • Activity.lifecycleScope.launch: 立即启动协程,并在 Activity 销毁时取消协程;
  • Fragment.lifecycleScope.launch: 立即启动协程,并在 Fragment 销毁时取消协程;
  • Fragment.viewLifecycleOwner.lifecycleScope.launch: 立即启动协程,并在 Fragment 中视图销毁时取消协程。

这些协程 API 只有在最后组件 / 视图销毁时才会取消协程,当视图进入后台时协程并不会被取消,Flow 会持续生产数据,并且会触发更新视图。

  • LifecycleContinueScope.launchWhenX: 在生命周期到达指定状态时立即启动协程执行代码块,在生命周期低于该状态时挂起(而不是取消)协程,在生命周期重新高于指定状态时,自动恢复该协程。

    这些协程 API 在视图离开某个状态时会挂起协程,能够避免更新视图。但是 Flow 会持续生产数据,也会产生一些不必要的操作和资源消耗(CPU 和内存)

APT

注解处理器Annotation Processing Tool:APT 是一种处理注释的工具, 它对源代码文件进行检测找出其中的注解,并使用注解进行额外的处理

APT 能在编译期根据编译阶段注解,给我们自动生成代码,简化使用。很多流行框架都使用到了 APT 技术,如 ButterKnife,Retrofit,Arouter,EventBus 3.0 等等

声明一个注解处理器

每一个注解处理器都需要承AbstractProcessor类:

class MineProcessor extends AbstractProcessor {

    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {}
    
    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        return false;
    }

    @Override
    public SourceVersion getSupportedSourceVersion() { }

    @Override
    public Set<String> getSupportedAnnotationTypes() { }
}
  • init(ProcessingEnvironment processingEnv):每个注解处理器被初始化的时候都会被调用,该方法会被传入ProcessingEnvironment 参数。ProcessingEnvironment 能提供很多有用的工具类,Elements、Types和Filer。后面我们将会看到详细的内容。
  • process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv):注解处理器实际处理方法,一般要求子类实现该抽象方法,你可以在在这里写你的扫描与处理注解的代码,以及生成Java文件。其中参数RoundEnvironment ,可以让你查询出包含特定注解的被注解元素,后面我们会看到详细的内容。
  • getSupportedAnnotationTypes(): 返回当前注解处理器处理注解的类型,返回值为一个字符串的集合。其中字符串为处理器需要处理的注解的合法全称
  • getSupportedSourceVersion():用来指定你使用的Java版本,通常这里返回SourceVersion.latestSupported()。如果你有足够的理由指定某个Java版本的话,你可以返回SourceVersion.RELAEASE_XX。但是还是推荐使用前者。

Hook

用于绕过系统限制、修改别人发布的代码、动态化、调用隐藏API、插件化、组件化、自动化测试、沙箱

img

反射/动态代理

作用于Java层。反射/动态代理是虚拟机提供的标准编程接口,可靠性较高。反射API可以帮助我们们访问到private属性并修改,动态代理可以直接从Interface中动态的构造出代理对象,并去监控这个对象。

常见的用法是,用动态代理构造出一个代理对象,然后用反射API去替换进程中的对象,从而达到hook的目的。如:对Java Framework API的修改常用这种方法,修改ActivityThread、修当前进程的系统调用等。

缺点:只在java层,只能通过替换对象达到目的,适用范围较小

优点:稳定性好,调用反射和动态代理并不存在适配问题,技术门槛低

companion object {
        @SuppressLint("DiscouragedPrivateApi", "PrivateApi")
        fun hook(context: Context, view: View) {
            try {
                // 反射执行View类的getListenerInfo()方法,拿到v的mListenerInfo对象,这个对象就是点击事件的持有者
                val method: Method = View::class.java.getDeclaredMethod("getListenerInfo")
                //由于getListenerInfo()方法并不是public的,所以要加这个代码来保证访问权限
                method.isAccessible = true
                //这里拿到的就是mListenerInfo对象,也就是点击事件的持有者
                val mListenerInfo = method.invoke(view)
                // 要从这里面拿到当前的点击事件对象
                val listenerInfoClz: Class<*> = Class.forName("android.view.View\$ListenerInfo")
                // 这是内部类的表示方法
                val field = listenerInfoClz.getDeclaredField("mOnClickListener")
                //取得真实的mOnClickListener对象
                val onClickListenerInstance = field[mListenerInfo] as View.OnClickListener

                //创建我们自己的点击事件代理类
                //方式一:自己创建代理类
                val proxyOnClickListener1 = ProxyOnClickListener(onClickListenerInstance)
                //方式二:由于View.OnClickListener是一个接口,所以可以直接用动态代理模式

                // 2. 创建我们自己的点击事件代理类
                // Proxy.newProxyInstance的3个参数依次分别是:
                // 1.本地的类加载器;
                // 2.代理类的对象所继承的接口(用Class数组表示,支持多个接口)
                // 3.代理类的实际逻辑,封装在new出来的InvocationHandler内
                val proxyOnClickListener2 = Proxy.newProxyInstance(
                    context.javaClass.classLoader,
                    arrayOf<Class<*>>(View.OnClickListener::class.java)
                ) { proxy, method, args ->
                    //点击事件被hook到了,加入自己的逻辑
                    //执行被代理的对象的逻辑
                    method.invoke(onClickListenerInstance, *args) 
                }
                // 3. 用我们自己的点击事件代理类,设置到"持有者"中
                field[mListenerInfo] = proxyOnClickListener2
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
    }

    // 自定义代理类
    internal class ProxyOnClickListener(var oriLis: View.OnClickListener?) :
        View.OnClickListener {
        override fun onClick(v: View) {
            //点击事件被hook到了,加入自己的逻辑
            oriLis?.onClick(v)
        }
    }

JNI Hook

java代码和native之间的调用是通过JNI接口调用的,所有JNI接口的函数指针都会被保存在虚拟机的一张表中。所以,java和native之间调用可以通过修改函数指针达到。

优点:稳定性高

缺点:只能hook Java和Native之间的native接口函数

ClassLoader

java代码的执行都是靠虚拟机的类加载器ClassLoader去加载,ClassLoader默认的双亲委派机制保证了ClassLoader总是从父类优先去加载java class。所以一类hook方案就是通过修改ClassLoader加载java class的Path路径达到目的。常见的应用场景有一些热修复技术。

优点:稳定性高

缺点:需要提前编译好修改后的class去替换,灵活性降低了

模块化、组件化、插件化

单工程模式

new Project -> 分包 -> ... 这种模式不涉及乱七八糟的处理方式, 上手快,开发快,足够敏捷。Mobile Project 刚起步,项目都偏小,一些附加业务还没绑到App上

模块化

模块化是将功能拆分,分成相互独立的模块,以便于每个模块只包含与其自身功能相关的内容。

就是"业务框架"或者“业务模块",也可以理解为“框架”,意思是把功能进行划分,将同一类型的代码整合在一起,所以模块的功能相对复杂,但都同属于一个业务使用:按照项目功能需求划分成不同类型的业务框架(例如:注册、登录、运动、商城等.....)

目的:隔离/封装 (高内聚)

依赖:模块之间有依赖的关系,可通过路由器进行模块之间的耦合问题

架构定位:横向分块(位于架构业务框架层)

特点:从代码逻辑的角度进行划分,方便代码分层开发,保证每个功能模块的职能单一

组件化

组件化就是基于可重用为目的的,将一个大的软件系统按照分离关注点的形式,拆分多个独立的组件,减少耦合。

就是“基础库”或者“基础组件",意思是把代码重复的部分提炼出一个个组件供给功能使用

使用:Dialog,各种自定义的UI控件、能在项目或者不同项目重复应用的代码等等

目的:复用,解耦

依赖:组件之间低依赖,比较独立

架构定位:纵向分层(位于架构底层,被其他层所依赖)

特点:从UI界面的角度进行划分,前端的组件化,方便UI组件的重用

插件化

插件化严格意义来讲,其实也算是模块化的观念。将一个完整的工程,按业务划分为不同的插件,都是分治法的一种体现。化整为零,相互配合。越小的模块越容易维护,插件化按理也算是模块化的一种体现,和组件化就不是一个概念了。

组件化的单位是组件(module)

插件化的单位是apk(一个完整的应用)

组件化实现的是解耦与加快编译, 隔离不需要关注的部分

插件化实现的也是解耦与加快编译,同时实现热插拔也就是热更新

组件化的灵活性在于按加载时机切换,分离出独立的业务组件

插件化的灵活性在于加载apk, 完全可以动态下载,动态更新,比组件化更灵活

下一篇:Android面试知识点总结(三)—— 通信/服务/网络篇