rokevin
移动
前端
语言
  • 基础

    • Linux
    • 实施
    • 版本构建
  • 应用

    • WEB服务器
    • 数据库
  • 资讯

    • 工具
    • 部署
开放平台
产品设计
  • 人工智能
  • 云计算
计算机
其它
GitHub
移动
前端
语言
  • 基础

    • Linux
    • 实施
    • 版本构建
  • 应用

    • WEB服务器
    • 数据库
  • 资讯

    • 工具
    • 部署
开放平台
产品设计
  • 人工智能
  • 云计算
计算机
其它
GitHub
  • 斗鱼 面试

  • 常用的图片框架有哪些?
  • 如何加载网上的图片资源?
  • 如何获取一个根目录下的图片?
  • 图片放置都有哪些模式,需要设置哪个参数?
  • 通过 src 设置图片和通过 bg 设置图片有什么区别?
  • 网络框架了解吗?除了 Retrofit,还知道哪些网络框架并用过吗?
  • OkHttp
  • Volley
  • HttpClient
  • HttpURLConnection
  • 聊到 okhttp,介绍其线程调度、缓存、拦截器。
  • 线程调度
  • 缓存
  • 拦截器
  • Http 报文格式(字段作用),请求方法有哪些?GET 和 POST 区别是什么?
  • Http 报文格式及字段作用
  • Http 请求方法
  • GET 和 POST 区别
  • 状态码有哪些?项目中具体用到哪些?
  • 常见的 HTTP 状态码
  • 项目中具体用到的状态码
  • Https 建立连接过程是怎样的?
  • Https 建立连接过程
  • Https 如何加密以及如何进行身份验证?
  • DNS 劫持是怎么一回事?
  • Cookie 相关知识有哪些?
  • 安卓的最新版本知道是几吗?有哪些最新特性?
  • 有自己适配过 Dark Mode 吗?
  • Android 的权限了解吗?如何申请一个权限?
  • Android 的通知了解吗?现在 APP 如何发出一个通知?
  • Activity 启动模式有哪些,举例说明。
  • Activity A 启动 Activity B 时两个 Activity 的生命周期变化,B 回到 A 时的生命周期变化。
  • Service 是运行在子线程吗,能直接进行耗时操作吗?IntentService 说一下。
  • Android 的权限了解吗?如何申请一个权限?
  • Android 的通知了解吗?现在 APP 如何发出一个通知?
  • equals 和 hashcode 有什么关系?默认情况下,对两对象使用 equals 返回 true,两者 hashcode 是否相等?
  • Java 中线程的几种状态有哪些,如何转换?
  • synchronized 说一下,乐观锁悲观锁的概念是什么?
  • volatile 的作用和原理是什么?
  • JVM 内存结构说一下,垃圾回收算法说一下?
  • 垃圾回收机制了解吗,老年代有什么算法?
  • HashMap 中插入的 key 要注意什么?HashMap 扩容机制是怎样的?
  • 说说 HashMap 的原理。
  • 类加载机制了解吗?DexClassLoader 与 PathClassLoader 的区别是什么?
  • 不用额外变量交换两个变量的值,把知道的方法都说一下。
  • 单例模式写一下,包括手写单例 DCL 及为什么用 volatile,了解枚举实现单例吗?
  • 事件分发机制说一下,怎么写一个不能滑动的 ViewPager。
  • 自定义 View 的基本流程说一下。
  • RecyclerView 的缓存机制了解吗?
  • 二叉树的先序遍历怎么实现?如何得到一个二叉树的高度?如何遍历 View 树?
  • 二叉树的先序遍历实现
  • 二叉树的高度获取
  • 遍历 View 树
  • 手写快排算法。
  • 手撕快速排序(作业帮)
  • 为什么学 Android,怎么学的?
  • 学习 Android 的原因
  • 学习的方式
  • MVP 模式和 MVC 模式的优缺点是什么?
  • MVC 模式
  • MVP 模式
  • MVP 模式中 M 和 V 如何直接通信,不经过 P 层?
  • 模型层(Model)
  • 视图层(View)
  • 一个线程能有几个 handler,几个 looper?
  • 能在子线程中创建 handler 吗,如何创建?
  • 如何防止多次点击?如果不用 singleTop 如何实现?
  • 利用时间戳判断
  • 使用 RxJava 实现
  • 如何记录时间?如果用户两次点击之间修改了系统时间,使得两次点击时间的差值为负数怎么办?
  • 记录时间的方式
  • 使用 System.currentTimeMillis ()
  • 使用 SystemClock 类
  • 应对系统时间修改导致时间差值为负数的情况
  • 说说对插件化的原理,资源的插件化 id 重复如何解决?
  • 插件化原理
  • 类加载机制的利用
  • 组件的生命周期管理
  • 资源的加载与管理
  • 资源的插件化 id 重复如何解决
  • 资源包名隔离
  • 资源 id 重映射
  • 资源合并时的冲突处理
  • mvp 与 mvvm 模式的区别是什么?
  • MVP 模式
  • MVVM 模式
  • JetPack 组件用过哪些?lifeCycle 的原理是什么?如果在 onStart 里面订阅,会回调 onCreate 吗?
  • 用过的 JetPack 组件
  • Lifecycle 的原理
  • 在 onStart 里面订阅,会回调 onCreate 吗?
  • 单例模式有什么缺点?
  • 内存泄漏风险
  • 多线程并发问题
  • 可测试性受限
  • 代码耦合度增加
  • 说说 App 的启动过程,在 ActivityThread 的 main 方法里面做了什么事,什么时候启动第一个 Activity?
  • App 的启动过程
  • ActivityThread 的 main 方法里面做的事
  • 启动第一个 Activity 的时间
  • 说说对 Handler 机制的了解,同步消息,异步消息等。
  • Handler 机制概述
  • 同步消息
  • 异步消息
  • 说说对屏幕刷新机制的了解,双重缓冲,三重缓冲,黄油模型。
  • 屏幕刷新机制概述
  • 双重缓冲
  • 三重缓冲
  • 黄油模型
  • onCreate、onResume、onStart 里面,什么地方可以获得宽高?
  • 在 onCreate 方法中
  • 在 onStart 方法中
  • 在 onResume 方法中
  • 为什么 view.post 可以获得宽高,有看过 view.post 的源码吗?
  • 为什么 view.post 可以获得宽高
  • view.post 的源码分析
  • attachToWindow 什么时候调用?
  • attachToWindow 的调用时机
  • DataBinding 的原理了解吗?
  • DataBinding 的原理
  • 编译时处理
  • 数据绑定的建立
  • 与视图生命周期的配合

斗鱼 面试

常用的图片框架有哪些?

  • Glide:是一个快速高效的 Android 图片加载库,专注于平滑滚动。它支持多种图片格式,包括 GIF,具有高效的缓存策略,能自动管理图片的生命周期,避免内存泄漏和 OOM 错误。其 API 简洁易用,可轻松实现图片的加载、显示和缓存等功能,如一行代码即可实现图片从网络或本地的加载并显示在 ImageView 中。
  • Picasso:由 Square 公司开发,使用简单,能自动处理图片的缓存和加载,支持从网络、本地文件系统、资源文件等多种来源加载图片。在加载图片时,会自动根据 ImageView 的大小调整图片的尺寸,减少内存占用,同时提供了丰富的图片变换和裁剪功能。
  • Fresco:Facebook 开源的图片库,主要特点是在加载图片时,将图片以流的形式进行处理,支持渐进式加载,先显示低质量的图片,然后随着加载的进行逐渐显示高质量的图片,从而提高用户体验。它对大图片和长图的处理有很好的优化,能有效避免 OOM。
  • Coil:是一个轻量级的图片加载库,基于 Kotlin 和协程实现,具有简洁的 API 和高效的性能,支持多种图片源和缓存策略,能够快速加载图片并显示在 UI 上,适用于现代 Android 应用的开发需求。

如何加载网上的图片资源?

  • 使用上述提到的图片框架,以 Glide 为例,在项目的 build.gradle 文件中添加依赖后,在代码中可以使用如下方式加载网络图片:
Glide.with(context)
    .load("图片的网络地址")
    .into(imageView);
  • 也可以使用 Android 原生的网络请求库结合 ImageView 来加载,首先发送网络请求获取图片的字节流,然后将字节流转换为 Bitmap,最后设置到 ImageView 中显示,示例代码如下:
public class MainActivity extends AppCompatActivity {
    private ImageView imageView;
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
 
        imageView = findViewById(R.id.imageView);
 
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    URL url = new URL("图片的网络地址");
                    HttpURLConnection connection = (HttpURLConnection) url.openConnection();
                    connection.setDoInput(true);
                    connection.connect();
                    InputStream inputStream = connection.getInputStream();
                    final Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
 
                    runOnUiThread(new Runnable() {
                        @Override
                        public void run() {
                            imageView.setImageBitmap(bitmap);
                        }
                    });
 
                    inputStream.close();
                    connection.disconnect();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
}

如何获取一个根目录下的图片?

  • 如果是内部存储的根目录,可以使用以下方法获取图片文件:
File internalRoot = getFilesDir();
File imageFile = new File(internalRoot, "图片文件名.jpg");
if (imageFile.exists()) {
    // 对图片文件进行操作,如读取或显示
}
  • 如果是外部存储的根目录,需要先检查外部存储是否可用,然后再获取图片文件:
if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
    File externalRoot = Environment.getExternalStorageDirectory();
    File imageFile = new File(externalRoot, "图片文件名.jpg");
    if (imageFile.exists()) {
        // 对图片文件进行操作
    }
}
  • 还可以使用 ContentResolver 来查询系统中的图片,例如查询媒体库中的图片:
ContentResolver contentResolver = getContentResolver();
Uri uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
String[] projection = {MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA};
Cursor cursor = contentResolver.query(uri, projection, null, null, null);
if (cursor!= null && cursor.moveToFirst()) {
    int dataColumnIndex = cursor.getColumnIndex(MediaStore.Images.Media.DATA);
    String imagePath = cursor.getString(dataColumnIndex);
    File imageFile = new File(imagePath);
    // 对图片文件进行操作
    cursor.close();
}

图片放置都有哪些模式,需要设置哪个参数?

  • fitXY:该模式会拉伸图片,使其完全填充 ImageView 的宽高,可能会导致图片变形。在代码中设置时,对于 ImageView 可以使用 android:scaleType="fitXY"。
  • fitCenter:会等比例缩放图片,使图片在 ImageView 中居中显示,可能会在 ImageView 的上下或左右留出空白。设置方法为 android:scaleType="fitCenter"。
  • centerCrop:按比例缩放图片,使图片的宽高都等于或大于 ImageView 的宽高,然后裁剪图片的中间部分以填充 ImageView,保证图片充满整个 ImageView。对应的设置是 android:scaleType="centerCrop"。
  • centerInside:按比例缩放图片,使图片的宽高都小于或等于 ImageView 的宽高,然后将图片居中显示在 ImageView 中,图片可能无法填满 ImageView。设置方式为 android:scaleType="centerInside"。
  • matrix:不进行任何缩放,以原始图片的大小显示在 ImageView 的左上角,如果图片大于 ImageView,超出部分将被裁剪。在代码中使用 android:scaleType="matrix" 进行设置。

通过 src 设置图片和通过 bg 设置图片有什么区别?

  • 功能用途方面:src 主要用于设置 ImageView 中显示的主要内容图片,通常是应用中需要突出展示、用户交互的核心图片,如头像、商品图片等;而 bg 则是用于设置 ImageView 的背景图片,更多的是起到装饰、衬托的作用,如为一个按钮设置背景图片,或者为一个布局容器设置背景图片以营造特定的视觉效果。
  • 图片显示效果方面:src 设置的图片会按照 ImageView 的 scaleType 属性进行缩放、裁剪等调整以适应 ImageView 的大小和显示要求;而 bg 设置的图片通常会被拉伸或平铺以填充 ImageView 的整个背景区域,如果图片尺寸小于 ImageView,可能会出现重复平铺的情况,如果大于 ImageView,则可能会被裁剪。
  • 对 ImageView 大小的影响方面:当使用 src 设置图片时,ImageView 的大小通常会根据图片的大小和 scaleType 进行调整,以确保图片能够完整显示;而当使用 bg 设置图片时,ImageView 的大小主要由布局中的其他约束条件决定,背景图片会自动适应 ImageView 的大小。
  • 在触摸事件处理方面:src 设置的图片所在的 ImageView 通常会接收和处理触摸事件,用户可以对图片进行点击、长按等操作;而 bg 设置的图片作为背景,一般不会直接响应触摸事件,除非 ImageView 本身设置了相应的触摸事件处理逻辑。

网络框架了解吗?除了 Retrofit,还知道哪些网络框架并用过吗?

除了 Retrofit,还有许多优秀的网络框架,例如:

OkHttp

这是一个高效的 HTTP 客户端,被广泛应用于 Android 和 Java 应用中。它支持 HTTP/2 协议,连接池复用连接,减少了网络延迟和资源消耗。其内部实现了拦截器链,可以方便地进行请求和响应的拦截和处理,如添加请求头、缓存控制、日志打印等。在实际使用中,通过构建 OkHttpClient 对象,然后使用 Request 对象发起请求,最后通过 Call 对象获取响应,代码简洁明了,适用于各种复杂的网络请求场景。

Volley

是 Google 推出的网络框架,特别适合于数据量小、通信频繁的网络操作。它提供了一套简单而强大的 API,支持多种请求方式,如 GET、POST 等,并且可以方便地设置请求头、请求参数等。Volley 内部对网络请求进行了封装和优化,自动处理了网络缓存、多线程请求等复杂的逻辑,还可以对请求进行优先级排序,确保重要的请求优先处理,提高了网络请求的效率和性能。

HttpClient

这是 Apache Jakarta Common 项目下的一个子项目,提供了丰富而全面的 HTTP 客户端功能。它支持多种 HTTP 协议版本,能够方便地进行 HTTP 请求的发送和响应的处理,具有高度的可定制性。可以通过设置各种参数和配置来满足不同的业务需求,如设置连接超时时间、请求重试次数等。不过,由于 Android 6.0 之后 HttpClient 的维护和更新逐渐减少,在新的项目中使用相对较少,但在一些旧项目中仍然可能会看到它的身影。

HttpURLConnection

是 Java 的标准类库中提供的用于发送 HTTP 请求和获取 HTTP 响应的类。它提供了基本的 HTTP 请求功能,使用简单,无需引入额外的库。可以通过设置请求方法、请求头、请求参数等进行网络请求,并且能够获取响应码、响应头和响应内容等。虽然其功能相对较为基础,但在一些简单的网络请求场景中仍然可以使用,并且由于它是 Java 标准库的一部分,具有较好的兼容性和稳定性。

聊到 okhttp,介绍其线程调度、缓存、拦截器。

线程调度

OkHttp 内部使用了线程池来管理网络请求的线程调度。当发起一个网络请求时,OkHttp 会从线程池中获取一个线程来执行请求任务,如果线程池中没有可用的线程,则会等待直到有线程空闲。在请求完成后,线程会将结果返回给主线程或者指定的回调线程。这样可以避免频繁地创建和销毁线程,提高了线程的复用率和系统的性能。同时,OkHttp 还可以根据网络情况和请求的优先级自动调整线程的调度策略,确保重要的请求能够及时得到处理。

缓存

OkHttp 的缓存机制基于 HTTP 协议的缓存规范,支持多种缓存策略,如强缓存和协商缓存。它会自动根据服务器返回的缓存头信息来判断是否可以使用缓存,如果缓存可用,则直接从缓存中获取数据,而不需要再次发起网络请求,从而提高了网络请求的效率和减少了用户的流量消耗。在代码中,可以通过设置 OkHttpClient 的缓存配置来定制缓存的大小、缓存的位置等,并且可以通过拦截器来进一步控制缓存的行为,如添加自定义的缓存策略、清除缓存等。

拦截器

OkHttp 的拦截器是其强大功能之一,它允许在请求和响应的过程中进行拦截和处理。拦截器链由多个拦截器组成,每个拦截器都可以对请求和响应进行修改、记录日志、添加头信息等操作。例如,可以添加一个日志拦截器来打印请求和响应的详细信息,方便调试和排查问题;还可以添加一个缓存拦截器来实现自定义的缓存策略,或者添加一个认证拦截器来处理用户的身份验证等。拦截器的顺序是按照添加的顺序依次执行,通过合理地组合和使用拦截器,可以实现各种复杂的网络请求处理逻辑。

Http 报文格式(字段作用),请求方法有哪些?GET 和 POST 区别是什么?

Http 报文格式及字段作用

  • 请求报文:由请求行、请求头、空行和请求体组成。请求行包含请求方法、请求的 URL 和 HTTP 协议版本。请求头包含了许多键值对,如 User-Agent 字段用于标识客户端的类型和版本,Accept 字段用于告诉服务器客户端能够接受的内容类型,Cookie 字段用于在客户端和服务器之间传递会话信息等。空行用于分隔请求头和请求体,请求体则包含了具体的请求数据,如 POST 请求中的表单数据等。
  • 响应报文:由状态行、响应头、空行和响应体组成。状态行包含 HTTP 协议版本、状态码和状态码的描述信息。响应头同样包含许多键值对,如 Content-Type 字段用于告诉客户端响应内容的类型,Content-Length 字段用于表示响应体的长度,Set-Cookie 字段用于在服务器向客户端设置会话信息等。空行用于分隔响应头和响应体,响应体则包含了服务器返回的具体数据,如 HTML 页面、JSON 数据等。

Http 请求方法

常见的 HTTP 请求方法有 GET、POST、PUT、DELETE、HEAD、OPTIONS 等。GET 方法用于从服务器获取资源,通常用于查询操作,请求参数会附加在 URL 后面。POST 方法用于向服务器提交数据,通常用于创建或更新资源,请求参数会放在请求体中。PUT 方法用于更新服务器上的资源,DELETE 方法用于删除服务器上的资源,HEAD 方法与 GET 方法类似,但只返回响应头,不返回响应体,OPTIONS 方法用于获取服务器支持的请求方法等信息。

GET 和 POST 区别

  • 请求参数传递方式:GET 请求的参数会附加在 URL 后面,以 “?” 开头,多个参数之间用 “&” 连接,如 “http://example.com/api?param1=value1&param2=value2”,这种方式参数可见,且长度有限制;POST 请求的参数则放在请求体中,相对更加安全,参数长度限制较小。
  • 语义和用途:GET 主要用于获取数据,是幂等的,即多次请求相同的 URL 应该返回相同的结果;POST 主要用于向服务器提交数据,可能会对服务器的资源产生影响,不是幂等的。
  • 缓存性:GET 请求的结果通常可以被浏览器缓存,下次请求相同的 URL 时,如果缓存未过期,则可以直接从缓存中获取数据;POST 请求的结果一般不会被缓存,因为每次提交的数据可能不同,需要重新向服务器获取最新的数据。
  • 安全性:由于 GET 请求的参数在 URL 中可见,所以相对 POST 请求来说安全性较低,POST 请求的参数在请求体中,相对不容易被直接获取到。
  • 对服务器的影响:GET 请求对服务器的影响通常较小,主要是获取数据;POST 请求可能会对服务器的资源产生影响,如创建或更新数据等,可能需要更多的服务器处理资源。

状态码有哪些?项目中具体用到哪些?

常见的 HTTP 状态码

  • 1xx:表示信息性状态码,如 100 Continue,表示客户端可以继续发送请求。
  • 2xx:表示成功状态码,如 200 OK,表示请求成功,服务器返回了客户端所请求的资源;201 Created,表示请求成功并且服务器创建了新的资源;204 No Content,表示请求成功,但服务器没有返回任何内容。
  • 3xx:表示重定向状态码,如 301 Moved Permanently,表示请求的资源已被永久移动到新的位置,客户端应使用新的 URL 重新请求;302 Found,表示请求的资源临时移动到了其他位置,客户端应使用新的 URL 重新请求;304 Not Modified,表示客户端的缓存资源是最新的,无需再次从服务器获取。
  • 4xx:表示客户端错误状态码,如 400 Bad Request,表示客户端发送的请求有语法错误,服务器无法理解;401 Unauthorized,表示客户端请求需要身份验证,但客户端未提供有效的身份验证信息;403 Forbidden,表示客户端没有权限访问请求的资源;404 Not Found,表示请求的资源在服务器上不存在。
  • 5xx:表示服务器错误状态码,如 500 Internal Server Error,表示服务器内部出现错误,无法完成客户端的请求;502 Bad Gateway,表示服务器作为网关或代理,从上游服务器接收到无效的响应;503 Service Unavailable,表示服务器暂时无法处理客户端的请求,可能是由于服务器过载或维护等原因。

项目中具体用到的状态码

在实际项目中,经常会用到 200 表示请求成功获取到数据,例如在获取新闻列表、商品列表等数据时,如果服务器成功返回数据,就会返回 200 状态码。在用户登录或注册时,如果用户名或密码错误,服务器可能会返回 401 Unauthorized 状态码,表示身份验证失败。当用户请求访问一个不存在的页面或资源时,服务器会返回 404 Not Found 状态码。在服务器出现内部错误时,如数据库连接失败、业务逻辑处理错误等,会返回 500 Internal Server Error 状态码,此时客户端可以根据具体情况进行相应的处理,如提示用户 “服务器繁忙,请稍后重试” 等。

Https 建立连接过程是怎样的?

Https 建立连接过程

  • 客户端发送请求:客户端首先向服务器发送一个连接请求,请求建立 SSL/TLS 连接。这个请求中包含了客户端支持的 SSL/TLS 版本、加密算法等信息。
  • 服务器响应:服务器收到客户端的请求后,会根据自身的配置和支持的加密算法等,选择一个合适的 SSL/TLS 版本和加密算法,并将服务器的证书发送给客户端。服务器证书中包含了服务器的公钥、服务器的域名等信息。
  • 客户端验证服务器证书:客户端收到服务器证书后,会首先验证证书的合法性。客户端会检查证书是否由可信任的证书颁发机构颁发,证书是否过期,证书的域名是否与服务器的实际域名一致等。如果证书验证通过,客户端会从证书中提取出服务器的公钥。
  • 客户端生成随机密钥并加密发送:客户端使用服务器的公钥生成一个随机的对称密钥,这个对称密钥将用于后续的加密通信。客户端使用服务器的公钥对这个对称密钥进行加密,并将加密后的对称密钥发送给服务器。
  • 服务器解密获取对称密钥:服务器收到客户端发送的加密后的对称密钥后,使用自己的私钥对其进行解密,获取到客户端生成的对称密钥。
  • 双方使用对称密钥进行加密通信:客户端和服务器都获取到对称密钥后,双方开始使用这个对称密钥对后续的通信数据进行加密和解密。在整个通信过程中,双方发送的数据都会使用对称密钥进行加密,接收方收到数据后再使用对称密钥进行解密,从而保证了通信的安全性和保密性。

Https 如何加密以及如何进行身份验证?

Https 通过 SSL/TLS 协议来实现加密和身份验证。在加密方面,主要采用了非对称加密和对称加密相结合的方式。首先,客户端向服务器发起连接请求,服务器会将自己的公钥发送给客户端。客户端使用这个公钥对随机生成的对称密钥进行加密,然后发送给服务器。服务器使用自己的私钥解密得到对称密钥。之后,双方就可以使用这个对称密钥对数据进行加密传输,对称加密算法如 AES 等,加密速度快,适合大量数据传输,这样就保证了数据在传输过程中的保密性。

在身份验证上,服务器会向客户端发送数字证书。数字证书包含了服务器的公钥以及服务器的身份信息等,并且由权威的证书颁发机构(CA)进行签名。客户端收到证书后,会验证证书的合法性,包括检查证书是否在有效期内、证书的颁发机构是否可信、证书是否被篡改等。如果证书验证通过,客户端就可以确认服务器的身份是可信的,然后使用证书中的公钥进行后续的加密通信等操作。通过这种方式,既保证了数据的安全传输,又确保了通信双方身份的真实性和可靠性,有效防止了中间人攻击等安全威胁。

DNS 劫持是怎么一回事?

DNS 劫持是一种网络安全攻击手段,攻击者通过篡改域名系统(DNS)的解析结果,将用户原本要访问的合法网站的域名解析到一个恶意的 IP 地址上。正常情况下,当用户在浏览器中输入一个域名时,DNS 服务器会将该域名解析为对应的 IP 地址,用户的设备根据这个 IP 地址去访问相应的网站。然而,在 DNS 劫持发生时,攻击者会入侵 DNS 服务器或者利用网络中的漏洞,修改 DNS 服务器中的域名解析记录。

比如,用户原本想访问银行的官方网站,但由于 DNS 劫持,DNS 服务器将该银行域名解析成了一个钓鱼网站的 IP 地址。当用户访问时,实际上就会访问到这个钓鱼网站,而用户可能毫无察觉地在这个虚假网站上输入自己的账号、密码等敏感信息,导致信息泄露和财产损失。DNS 劫持还可能导致用户无法正常访问某些网站,或者被强制跳转到广告页面等。为了防范 DNS 劫持,用户可以使用可靠的 DNS 服务器,如公共 DNS 服务提供商的 DNS 服务器,或者在路由器上设置安全的 DNS 解析地址等。

Cookie 相关知识有哪些?

Cookie 是服务器发送到用户浏览器并保存在本地的一小段文本信息,主要用于在客户端存储用户的一些状态信息和浏览记录等。它通常包含了一些键值对,比如用户的登录凭证、浏览历史、购物车信息等。当用户再次访问该网站时,浏览器会自动将相应的 Cookie 发送给服务器,服务器可以根据 Cookie 中的信息来识别用户身份、提供个性化的服务等。

从安全性角度看,Cookie 可以设置过期时间,分为会话 Cookie 和持久 Cookie。会话 Cookie 在浏览器关闭后就会自动删除,而持久 Cookie 会在本地保存一定的时间,直到过期或者被用户手动删除。但是,Cookie 也存在一定的安全风险,如果被恶意获取,可能会导致用户的隐私泄露。例如,攻击者可能通过 XSS 攻击获取用户的 Cookie,然后冒用用户身份进行登录等操作。为了提高安全性,通常可以对 Cookie 进行加密处理,设置 HttpOnly 属性防止 JavaScript 脚本访问 Cookie,以及设置 Secure 属性确保只有在 HTTPS 连接下才传输 Cookie 等。

在使用场景方面,Cookie 常用于实现用户登录后的记住登录状态功能,以及在电商网站中保存用户的购物车信息等,方便用户下次访问时继续之前的操作,提高用户体验,但同时也需要注意对其进行合理的安全防护。

安卓的最新版本知道是几吗?有哪些最新特性?

截至 2024 年 12 月 30 日,安卓的最新版本是 Android 14。其带来了诸多新特性,在隐私和安全方面,系统进一步强化了应用对用户敏感数据的访问限制,应用在获取用户的位置、麦克风、摄像头等敏感权限时,需要用户更加明确的授权,并且用户可以更精细地控制每个应用对特定敏感数据的访问权限,例如可以单独设置某个应用在前台或后台时是否能访问位置信息等,更好地保护了用户的隐私。

在性能优化上,Android 14 对系统资源的管理更加智能,通过优化内存管理和后台任务处理机制,使得应用的启动速度更快、运行更加流畅,减少了卡顿和掉帧现象,提升了系统的整体性能和响应速度,让用户在使用多个应用切换和复杂操作时能获得更顺滑的体验。

在用户体验方面,系统提供了更丰富的自适应图标和动态主题选项,用户可以根据自己的喜好选择不同风格的图标和主题,并且系统会根据用户的选择自动适配应用的外观,使整个系统界面更加美观和个性化。同时,系统还优化了手势导航功能,让用户在操作手机时更加便捷和自然,如返回、主页和多任务切换等手势更加流畅和易于使用。

有自己适配过 Dark Mode 吗?

在进行 Dark Mode 适配时,首先需要在资源文件中创建对应的暗黑主题资源,例如在 res/values-night 目录下创建与 res/values 目录下相对应的颜色、尺寸、样式等资源文件。在颜色资源方面,为每个在不同主题下需要变化的颜色定义不同的色值,比如在暗黑模式下,背景色可能会设置为较深的颜色,而文字颜色则会设置为相对较浅的颜色,以保证在暗黑模式下的可读性和视觉效果。

对于布局文件,如果某些布局在暗黑模式下需要调整,也可以在 res/layout-night 目录下创建对应的布局文件进行特殊处理。在代码中,可以通过获取当前系统的主题模式来动态地加载不同的资源,例如使用 ContextCompat.getColor () 方法来获取当前主题下对应的颜色资源,确保应用在不同主题下都能正确显示。

在适配过程中,还需要对图片资源进行处理,对于一些在暗黑模式下可能会显得过于明亮或者不协调的图片,可以提供专门的暗黑模式版本图片,放在对应的 drawable-night 目录下。同时,要注意一些第三方库的适配情况,有些库可能需要进行额外的配置或者自定义才能在 Dark Mode 下正常显示。通过以上步骤和方法,可以较好地完成应用的 Dark Mode 适配,为用户提供更好的视觉体验,使其在不同的环境光下都能舒适地使用应用。

Android 的权限了解吗?如何申请一个权限?

Android 中的权限用于保护用户的隐私和设备的安全,分为普通权限和危险权限。普通权限通常不会直接涉及用户隐私,在安装应用时会自动授予;危险权限则可能涉及用户的隐私信息,如读取联系人、获取位置等,需要在运行时由用户手动授予。

在 Android 中申请权限,首先需要在 AndroidManifest.xml 文件中声明需要的权限。例如,申请读取外部存储的权限:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.myapp">
 
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
 
    <application
      ...
    </application>
</manifest>

然后,在需要使用该权限的地方,通常是在 Activity 或 Fragment 中,使用checkSelfPermission()方法检查是否已经获取了该权限。如果没有获取,则使用requestPermissions()方法向用户请求权限,该方法会弹出一个系统对话框,提示用户授予权限。例如:

public class MainActivity extends AppCompatActivity {
 
    private static final int REQUEST_CODE_PERMISSION = 1;
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
 
        if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
               != PackageManager.PERMISSION_GRANTED) {
            ActivityCompat.requestPermissions(this,
                    new String[]{Manifest.permission.READ_EXTERNAL_STORAGE},
                    REQUEST_CODE_PERMISSION);
        } else {
            // 已经获取权限,可以进行相应操作
            loadImagesFromExternalStorage();
        }
    }
 
    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        if (requestCode == REQUEST_CODE_PERMISSION) {
            if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                // 用户授予了权限,可以进行相应操作
                loadImagesFromExternalStorage();
            } else {
                // 用户拒绝了权限,需要进行相应处理
                Toast.makeText(this, "没有获取到读取外部存储的权限", Toast.LENGTH_SHORT).show();
            }
        }
    }
 
    private void loadImagesFromExternalStorage() {
        // 在这里进行读取外部存储中图片的操作
    }
}

Android 的通知了解吗?现在 APP 如何发出一个通知?

Android 的通知是一种在设备的状态栏或其他地方向用户显示消息的机制,用于在应用处于后台或未运行时向用户传达重要信息、提醒或事件通知等。

要在 Android 应用中发出一个通知,首先需要创建一个NotificationCompat.Builder对象,通过该对象可以设置通知的各种属性,如标题、内容、图标、点击动作等。例如:

public class MainActivity extends AppCompatActivity {
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
 
        // 创建通知渠道
        createNotificationChannel();
 
        // 创建通知
        NotificationCompat.Builder builder = new NotificationCompat.Builder(this, "channel_id")
              .setSmallIcon(R.drawable.ic_notification)
              .setContentTitle("新消息")
              .setContentText("您有一条新的消息,请查看")
              .setPriority(NotificationCompat.PRIORITY_DEFAULT)
              .setAutoCancel(true);
 
        // 创建通知管理器
        NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
 
        // 发送通知
        notificationManager.notify(1, builder.build());
    }
 
    private void createNotificationChannel() {
        // 创建通知渠道
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            CharSequence name = "通知渠道名称";
            String description = "通知渠道描述";
            int importance = NotificationManager.IMPORTANCE_DEFAULT;
            NotificationChannel channel = new NotificationChannel("channel_id", name, importance);
            channel.setDescription(description);
 
            // 注册通知渠道
            NotificationManager notificationManager = getSystemService(NotificationManager.class);
            notificationManager.createNotificationChannel(channel);
        }
    }
}

在上述代码中,首先创建了一个通知渠道,这是 Android 8.0 及以上版本中必须的步骤,用于对通知进行分类和管理。然后创建了一个NotificationCompat.Builder对象,设置了通知的图标、标题、内容等属性,并通过NotificationManagerCompat发送通知。

Activity 启动模式有哪些,举例说明。

Android 中的 Activity 启动模式主要有以下几种:

standard:这是默认的启动模式,每次启动一个 Activity 都会创建一个新的实例并放入任务栈中。例如,在一个应用中有两个 Activity,A 和 B,当从 A 启动 B 时,会创建一个新的 B 实例并放入任务栈中,此时任务栈中有 A 和 B 两个实例,如果再从 B 启动 B,又会创建一个新的 B 实例放入任务栈中,此时任务栈中有 A 和 B1、B2 三个实例。

singleTop:如果要启动的 Activity 已经在任务栈的栈顶,就不会创建新的实例,而是直接复用栈顶的实例,并调用其onNewIntent()方法。例如,在一个应用中,有一个 Activity A,它的启动模式是singleTop,当从 A 启动 A 时,如果 A 已经在任务栈的栈顶,就不会创建新的 A 实例,而是直接调用 A 的onNewIntent()方法。

singleTask:在一个新的任务栈中创建该 Activity 的实例,如果该 Activity 已经存在于其他任务栈中,则将该任务栈切换到前台,并清除该 Activity 之上的所有其他 Activity 实例,只保留该 Activity 在栈顶。例如,有两个应用,应用 1 中有 Activity A 和 B,应用 2 中有 Activity C,A 的启动模式是singleTask,当从应用 2 的 C 中启动应用 1 的 A 时,会创建一个新的任务栈,并在该任务栈中创建 A 的实例,如果此时再从应用 1 的 B 中启动 A,会将包含 A 的任务栈切换到前台,并清除 A 之上的所有其他 Activity 实例,只保留 A 在栈顶。

singleInstance:该模式会为该 Activity 创建一个单独的任务栈,并且在整个系统中只有一个实例。例如,在一个应用中有 Activity A 和 B,A 的启动模式是singleInstance,当从 A 启动 B 时,会创建一个新的任务栈用于 B,而 A 在自己单独的任务栈中,无论从哪个应用或 Activity 中启动 A,都是使用同一个实例,并且 A 所在的任务栈始终只有 A 一个 Activity。

Activity A 启动 Activity B 时两个 Activity 的生命周期变化,B 回到 A 时的生命周期变化。

当 Activity A 启动 Activity B 时,生命周期变化如下:

  • Activity A:首先会执行onPause()方法,此时 Activity A 的界面仍然可见,但已经失去了焦点,不能再与用户进行交互。然后,如果 Activity B 的主题不是透明的,那么 Activity A 会接着执行onStop()方法,此时 Activity A 的界面不可见。
  • Activity B:会依次执行onCreate()、onStart()、onResume()方法,此时 Activity B 的界面可见并获得焦点,可以与用户进行交互。

当 Activity B 回到 Activity A 时,生命周期变化如下:

  • Activity B:首先会执行onPause()方法,此时 Activity B 的界面仍然可见,但已经失去了焦点,不能再与用户进行交互。然后会执行onStop()方法,此时 Activity B 的界面不可见。最后会执行onDestroy()方法,释放 Activity B 所占用的资源。
  • Activity A:如果 Activity A 在之前执行了onStop()方法,那么会先执行onRestart()方法,然后执行onStart()方法,最后执行onResume()方法,此时 Activity A 的界面可见并获得焦点,可以与用户进行交互。如果 Activity A 在之前没有执行onStop()方法,只是执行了onPause()方法,那么会直接执行onResume()方法。

Service 是运行在子线程吗,能直接进行耗时操作吗?IntentService 说一下。

Service 默认是运行在主线程中的,并不是运行在子线程。这意味着如果在 Service 的onCreate()、onStartCommand()等方法中直接进行耗时操作,可能会导致主线程被阻塞,从而出现 ANR(Application Not Responding)异常。

一般情况下,不能直接在 Service 中进行耗时操作,如果需要进行耗时操作,应该在 Service 中创建一个新的子线程来执行。例如,可以使用Thread类或者AsyncTask等方式在 Service 中开启子线程进行耗时操作。

IntentService 是 Service 的一个子类,它专门用于处理异步请求。IntentService 内部会创建一个工作线程来依次处理通过startService()方法传递过来的 Intent 请求,它会自动在后台线程中执行onHandleIntent()方法,不需要我们手动创建子线程,从而避免了在主线程中执行耗时操作的问题。当所有的请求都处理完成后,IntentService 会自动停止,不需要我们手动调用stopSelf()方法。例如:

public class MyIntentService extends IntentService {
 
    public MyIntentService() {
        super("MyIntentService");
    }
 
    @Override
    protected void onHandleIntent(@Nullable Intent intent) {
        // 在这里进行耗时操作,如下载文件、上传数据等
        String url = intent.getStringExtra("url");
        try {
            URL downloadUrl = new URL(url);
            HttpURLConnection connection = (HttpURLConnection) downloadUrl.openConnection();
            connection.connect();
            // 处理下载的数据等
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
 
    @Override
    public void onDestroy() {
        super.onDestroy();
        // 可以在这里进行一些资源释放等操作
    }
}

在上述代码中,MyIntentService继承自IntentService,在onHandleIntent()方法中可以进行耗时的网络请求等操作,而不用担心阻塞主线程。

Android 的权限了解吗?如何申请一个权限?

Android 权限用于限制应用对设备资源和功能的访问,分为普通权限和危险权限。普通权限在安装时自动授予,而危险权限需要用户明确授权。例如,访问网络、获取粗略位置等是普通权限,而读取短信、获取精确位置等是危险权限。

要申请一个权限,首先需要在 AndroidManifest.xml 文件中声明该权限,比如申请读取外部存储的权限:<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>。然后在需要使用该权限的地方,通常是在 Activity 或 Fragment 中,通过checkSelfPermission()方法检查是否已经获得权限,如果没有则调用requestPermissions()方法向用户请求权限,例如:

if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
       != PackageManager.PERMISSION_GRANTED) {
    ActivityCompat.requestPermissions(this,
            new String[]{Manifest.permission.READ_EXTERNAL_STORAGE},
            MY_PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE);
}

当用户对权限请求做出响应后,会回调onRequestPermissionsResult()方法,在该方法中可以根据用户的授权结果进行相应的处理,如:

@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults);
    if (requestCode == MY_PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) {
        if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
            // 权限已授予,执行相应操作
        } else {
            // 权限被拒绝,可提示用户或进行其他处理
        }
    }
}

Android 的通知了解吗?现在 APP 如何发出一个通知?

Android 的通知是一种在设备状态栏或其他地方向用户显示重要信息的方式,可用于提醒用户有新消息、任务完成、系统更新等。

要在 APP 中发出一个通知,首先需要创建一个NotificationCompat.Builder对象,通过该对象可以设置通知的各种属性,如标题、内容、图标、点击动作等。例如:

NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID)
       .setSmallIcon(R.drawable.notification_icon)
       .setContentTitle("新消息")
       .setContentText("您有一条新的消息")
       .setPriority(NotificationCompat.PRIORITY_DEFAULT);

然后需要创建一个通知渠道,这是 Android 8.0 及以上版本的要求,用于对通知进行分类管理,例如:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "默认渠道",
            NotificationManager.IMPORTANCE_DEFAULT);
    NotificationManager notificationManager = getSystemService(NotificationManager.class);
    notificationManager.createNotificationChannel(channel);
}

最后通过NotificationManager的notify()方法发送通知,需要传入一个唯一的通知 ID 和构建好的通知对象,如:

NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
notificationManager.notify(NOTIFICATION_ID, builder.build());

equals 和 hashcode 有什么关系?默认情况下,对两对象使用 equals 返回 true,两者 hashcode 是否相等?

在 Java 中,equals 和 hashCode 有着密切的关系。根据 Java 的规范,如果两个对象通过 equals 方法比较返回 true,那么它们的 hashCode 值必须相等。这是因为 hashCode 方法主要用于在散列数据结构(如 HashMap、HashSet 等)中快速定位对象,当两个对象被认为相等时,它们应该在散列结构中具有相同的位置,以保证散列结构的正确性和一致性。

然而,默认情况下,如果没有重写 equals 和 hashCode 方法,它们是基于对象的内存地址来判断相等性和生成哈希码的。在这种情况下,如果两个对象通过默认的 equals 方法返回 true,那么它们实际上是同一个对象,其 hashCode 自然是相等的。但如果重写了 equals 方法,根据自定义的相等逻辑判断两个对象相等时,就必须同时重写 hashCode 方法,以确保遵循上述规则。否则,可能会导致在使用散列数据结构时出现问题,例如在 HashMap 中可能会出现无法正确获取或存储对象的情况。

Java 中线程的几种状态有哪些,如何转换?

Java 中线程有以下几种状态:

  • 新建(New):当创建一个线程对象后,该线程处于新建状态,此时线程尚未开始执行。
  • 就绪(Runnable):线程对象创建后,调用 start 方法,线程进入就绪状态,此时线程等待获取 CPU 资源,一旦获得 CPU 资源就可以开始执行。
  • 运行(Running):线程获得 CPU 资源后,开始执行 run 方法中的代码,此时线程处于运行状态。
  • 阻塞(Blocked):线程在运行过程中,可能因为等待某些条件而暂时停止执行,进入阻塞状态。例如等待获取锁、等待 I/O 操作完成等。当阻塞条件解除后,线程会重新进入就绪状态。
  • 等待(Waiting):线程调用 Object 类的 wait 方法、Thread 类的 join 方法或 LockSupport 类的 park 方法等,会使线程进入等待状态,此时线程需要等待其他线程的通知或中断才能继续执行,被唤醒后进入就绪状态。
  • 定时等待(Timed Waiting):线程调用 Thread 类的 sleep 方法、Object 类的 wait 方法并指定超时时间、LockSupport 类的 parkNanos 方法或 parkUntil 方法等,会进入定时等待状态,在指定的时间到达后,线程会自动唤醒并进入就绪状态。
  • 终止(Terminated):线程执行完 run 方法中的代码,或者因为异常退出了 run 方法,线程就进入终止状态,此时线程的生命周期结束。

线程状态的转换通常是由操作系统的调度和线程自身的操作共同决定的。例如,新建的线程通过 start 方法进入就绪状态,就绪状态的线程在获得 CPU 资源后进入运行状态,运行状态的线程在遇到阻塞条件时进入阻塞或等待状态,等待条件满足或超时后又回到就绪状态,直到线程执行完毕进入终止状态。

synchronized 说一下,乐观锁悲观锁的概念是什么?

synchronized 是 Java 中的一种关键字,用于实现线程同步。它可以修饰方法或代码块,当一个线程访问被 synchronized 修饰的方法或代码块时,其他线程必须等待该线程执行完毕后才能访问,从而保证了在同一时刻只有一个线程能够访问被锁定的资源,避免了数据的不一致性和并发冲突。

  • 悲观锁:总是假设最坏的情况,认为在并发访问时一定会发生冲突,所以在每次访问数据时都会先加锁,以确保在自己操作数据期间不会有其他线程对数据进行修改。在 Java 中,synchronized 关键字和 ReentrantLock 类等都是悲观锁的实现。悲观锁适用于并发冲突概率较高的场景,它可以保证数据的一致性,但会导致线程的阻塞和等待,降低系统的并发性能。
  • 乐观锁:则假设在并发访问时不会发生冲突或者冲突的概率很低,所以在访问数据时不会加锁,而是在更新数据时才会去检查数据是否被其他线程修改过。如果数据没有被修改过,则直接更新数据;如果数据已经被修改过,则根据具体的策略进行处理,例如重试更新操作或抛出异常等。在 Java 中,乐观锁通常通过版本号机制或 CAS(Compare and Swap)算法来实现。乐观锁适用于并发冲突概率较低的场景,它可以提高系统的并发性能,但在高并发情况下可能会导致更新失败的重试次数增加,从而影响系统的性能。

volatile 的作用和原理是什么?

volatile 关键字在 Java 中有两个主要作用:

  • 保证可见性:在多线程环境下,当一个线程修改了被 volatile 修饰的变量的值,其他线程能够立即看到这个修改后的值。这是因为 volatile 关键字会强制将该变量的值从线程的工作内存刷新到主内存中,同时其他线程在读取该变量时,会从主内存中重新获取最新的值,而不是使用自己工作内存中的缓存值。
  • 禁止指令重排序:在 Java 编译器和处理器为了提高程序的执行效率,可能会对指令进行重排序。但是在多线程环境下,指令重排序可能会导致程序的执行结果与预期不一致。而 volatile 关键字可以禁止对其修饰的变量的指令重排序,从而保证在多线程环境下程序的正确性。

其原理主要基于 Java 内存模型(JMM)中的 happens-before 原则。当一个线程对 volatile 变量进行写操作时,会在写操作之前插入一个 StoreStore 屏障,在写操作之后插入一个 StoreLoad 屏障。这两个屏障的作用是确保在写 volatile 变量之前的所有内存操作都已经完成,并且在写操作完成之后,其他线程能够立即看到最新的值。同样,当一个线程对 volatile 变量进行读操作时,会在读操作之前插入一个 LoadLoad 屏障和一个 LoadStore 屏障,以确保在读取 volatile 变量时,能够获取到最新的值,并且在读取之后的内存操作不会被重排序到读操作之前。

JVM 内存结构说一下,垃圾回收算法说一下?

JVM 内存结构主要包括以下几个部分:

  • 程序计数器:是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在多线程环境下,程序计数器用于记录当前线程执行的位置,以便在线程切换后能够恢复到正确的执行位置。
  • Java 虚拟机栈:每个 Java 方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。随着方法的调用和返回,栈帧在 Java 虚拟机栈中入栈和出栈。
  • 本地方法栈:与 Java 虚拟机栈类似,但是它是为本地方法(用其他语言编写的方法,如 C 或 C++)服务的。
  • 堆:是 Java 虚拟机中最大的一块内存区域,用于存储对象实例和数组。所有的对象和数组都在堆中分配内存,堆是垃圾收集器管理的主要区域。
  • 方法区:用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。在 Java 8 中,方法区被元空间(Metaspace)所取代,元空间使用本地内存来存储类的元数据。

常见的垃圾回收算法有以下几种:

  • 标记 - 清除算法:首先标记出所有需要回收的对象,然后统一回收被标记的对象。这种算法的优点是简单,但是会产生大量的内存碎片,导致后续分配大对象时可能无法找到足够的连续内存空间。
  • 复制算法:将内存分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活的对象复制到另一块内存中,然后再把使用过的内存空间一次清理掉。这种算法的优点是不会产生内存碎片,但是内存利用率较低,只有一半的内存空间可以被使用。
  • 标记 - 压缩算法:首先标记出所有需要回收的对象,然后将所有存活的对象都向一端移动,最后清理掉端边界以外的内存。这种算法既避免了内存碎片的产生,又提高了内存的利用率,但是在移动对象时需要耗费一定的时间和空间。
  • 分代收集算法:根据对象的存活周期将内存划分为不同的代,一般分为新生代和老年代。新生代中对象的存活率较低,通常采用复制算法进行垃圾回收;老年代中对象的存活率较高,通常采用标记 - 清除或标记 - 压缩算法进行垃圾回收。这种算法结合了不同垃圾回收算法的优点,提高了垃圾回收的效率。

垃圾回收机制了解吗,老年代有什么算法?

垃圾回收机制是 Java 中自动管理内存的一种机制,它负责回收不再被程序使用的对象所占用的内存空间,以避免内存泄漏和提高内存的利用率。

老年代主要采用的垃圾回收算法有以下几种:

  • 标记 - 压缩算法:该算法首先标记出所有需要回收的对象,然后将存活的对象向一端移动,最后直接清理掉端边界以外的内存。这种算法可以避免内存碎片的产生,但移动存活对象的成本较高,适用于老年代对象存活率较高的情况。
  • 标记 - 清除算法:该算法首先标记出所有需要回收的对象,然后统一回收所有被标记的对象。这种算法的优点是实现简单,但是会产生大量的内存碎片,可能导致后续分配大对象时无法找到连续的内存空间而触发 Full GC。
  • 分代收集算法:这是一种基于不同代际对象特点的垃圾回收策略。在 Java 堆中,一般将内存分为新生代和老年代。新生代中的对象通常存活率较低,采用复制算法进行垃圾回收;而老年代中的对象存活率较高,通常采用标记 - 压缩算法或标记 - 清除算法进行垃圾回收。

HashMap 中插入的 key 要注意什么?HashMap 扩容机制是怎样的?

在 HashMap 中插入 key 时,需要注意以下几点:

  • key 的唯一性:HashMap 中的 key 是唯一的,如果插入了相同的 key,新的值会覆盖旧的值。因此在插入前需要确保 key 的唯一性,或者根据业务需求决定是否需要覆盖旧值。
  • key 的不可变性:为了保证 HashMap 的正常工作,插入的 key 应该是不可变的。如果 key 是可变对象,并且在插入后发生了变化,可能会导致无法正确获取到对应的 value,甚至破坏 HashMap 的内部结构。
  • key 的 hashCode 方法和 equals 方法:正确重写 key 的 hashCode 方法和 equals 方法至关重要。hashCode 方法应该根据 key 的内容生成一个合理的哈希码,以确保不同的 key 在散列表中有较好的分布。equals 方法应该准确地判断两个 key 是否相等,以保证在查找和操作时能正确定位到对应的键值对。

HashMap 的扩容机制如下:

  • 何时扩容:当 HashMap 中的元素个数超过了负载因子与当前容量的乘积时,就会触发扩容操作。默认的负载因子是 0.75,这意味着当 HashMap 中的元素数量达到容量的 75% 左右时,就会考虑扩容。
  • 扩容过程:扩容时,会创建一个新的数组,其容量通常是原数组容量的 2 倍。然后,将原数组中的所有元素重新计算哈希值,并根据新的容量分配到新的数组位置上。这个过程需要遍历原数组中的每个元素,重新计算其在新数组中的位置并插入,因此扩容操作是比较耗时的。

说说 HashMap 的原理。

HashMap 是 Java 中常用的一种数据结构,用于存储键值对。其原理基于哈希表实现,主要包括以下几个方面:

  • 哈希函数:HashMap 通过哈希函数将键对象的哈希码转换为数组的索引,以便快速定位到对应的桶位置。哈希函数的设计目标是尽量使不同的键均匀地分布在数组的各个桶中,以减少哈希冲突的发生。
  • 数据存储结构:HashMap 内部使用一个数组来存储键值对,每个数组元素称为一个桶。当多个键的哈希值相同时,会发生哈希冲突,此时会使用链表或红黑树来存储这些冲突的键值对。在 Java 8 中,当链表长度超过 8 且数组容量大于等于 64 时,链表会转换为红黑树,以提高查找和插入的效率。
  • 插入操作:当插入一个键值对时,首先根据键的哈希值计算出在数组中的桶位置。如果该桶为空,则直接将键值对插入该桶中;如果该桶不为空,则需要遍历链表或红黑树,判断键是否已存在。如果键已存在,则更新对应的值;如果键不存在,则将键值对插入到链表或红黑树中。
  • 查找操作:查找操作与插入操作类似,首先根据键的哈希值计算出桶位置,然后在对应的桶中遍历链表或红黑树,查找与给定键相等的键值对。如果找到,则返回对应的 value;如果未找到,则返回 null。
  • 删除操作:删除操作也需要先找到对应的键值对,然后将其从链表或红黑树中删除。如果删除后链表或红黑树的结构发生变化,可能需要进行一些调整,如将红黑树转换为链表等。

类加载机制了解吗?DexClassLoader 与 PathClassLoader 的区别是什么?

Java 的类加载机制是指将类的字节码文件加载到虚拟机内存中,并将其转换为可以被虚拟机直接使用的 Java 类的过程。类加载机制主要包括以下几个步骤:

  • 加载:通过类的全限定名获取定义此类的二进制字节流,将字节流所代表的静态存储结构转换为方法区的运行时数据结构,在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
  • 验证:确保被加载的类的字节码符合 Java 虚拟机的规范,包括文件格式验证、元数据验证、字节码验证和符号引用验证等。
  • 准备:为类的静态变量分配内存并设置默认初始值,这些变量所使用的内存都将在方法区中进行分配。
  • 解析:将类中的符号引用转换为直接引用,主要包括类或接口的解析、字段解析、类方法解析和接口方法解析等。
  • 初始化:执行类的初始化方法,即<clinit>() 方法,该方法是由编译器自动收集类中的所有静态变量的赋值动作和静态语句块合并产生的。

DexClassLoader 与 PathClassLoader 的区别如下:

  • 加载路径:PathClassLoader 主要用于加载系统类和应用程序的类,它只能加载已经安装到设备上的 APK 中的类和 dex 文件。而 DexClassLoader 可以加载指定路径下的 dex 文件或包含 dex 文件的 jar 包,它的加载路径更加灵活,可以用于加载未安装的 APK 中的类或其他自定义的 dex 文件。
  • 双亲委派模型:两者都遵循双亲委派模型,但在具体实现上有一些差异。PathClassLoader 在加载类时,会首先委托给父类加载器进行加载,只有当父类加载器无法加载时,才会尝试自己加载。DexClassLoader 在加载类时,也会遵循双亲委派模型,但它可以通过指定父类加载器来改变默认的委派行为,从而实现更灵活的类加载策略。
  • 应用场景:PathClassLoader 通常用于加载应用程序的主 dex 文件和系统类,是 Android 系统默认的类加载器。DexClassLoader 则常用于插件化开发、热修复等场景,通过加载外部的 dex 文件来实现动态加载和更新代码的功能。

不用额外变量交换两个变量的值,把知道的方法都说一下。

在 Java 中,可以使用以下几种方法在不使用额外变量的情况下交换两个变量的值:

  • 使用异或运算:异或运算的特点是两个相同的数异或结果为 0,一个数与 0 异或结果为该数本身。对于两个变量 a 和 b,可以通过以下三步完成交换:a = a ^ b; b = a ^ b; a = a ^ b; 首先将 a 和 b 的异或结果赋值给 a,此时 a 的值为 a ^ b。然后将 a 与 b 进行异或运算,即 (a ^ b) ^ b,根据异或运算的结合律和交换律,可化简为 a ^ (b ^ b),因为 b ^ b = 0,所以结果为 a,此时 b 的值变为了原来的 a。最后再将 a 与 b 进行异或运算,即 (a ^ b) ^ a,同样根据异或运算的性质,可化简为 b ^ (a ^ a),结果为 b,此时 a 的值变为了原来的 b。
  • 使用算术运算:可以通过加法和减法来实现变量交换。假设要交换的两个变量为 a 和 b,可以先将 a 和 b 的和赋值给 a,即 a = a + b; 然后将 a 减去 b 的值赋给 b,即 b = a - b; 此时 b 的值变为了原来的 a。最后再将 a 减去 b 的值赋给 a,即 a = a - b; 此时 a 的值变为了原来的 b。
  • 使用位运算中的左移和右移:对于整数类型的变量,可以利用位运算中的左移和右移操作来交换变量的值。假设要交换的两个变量为 a 和 b,且它们都是整数类型,可以先将 a 左移 32 位,然后与 b 进行按位或运算,将结果赋值给 a,即 a = (a << 32) | b; 此时 a 的高 32 位存储了原来的 a 的值,低 32 位存储了原来的 b 的值。然后将 a 右移 32 位,将结果赋值给 b,即 b = a >> 32; 此时 b 的值变为了原来的 a。最后再将 a 与 0xFFFFFFFF 进行按位与运算,然后左移 32 位,将结果赋值给 a,即 a = (a & 0xFFFFFFFF) << 32; 此时 a 的值变为了原来的 b。

单例模式写一下,包括手写单例 DCL 及为什么用 volatile,了解枚举实现单例吗?

  • 单例模式的定义:单例模式是一种设计模式,它确保一个类只有一个实例,并提供一个全局访问点来访问该实例。
  • DCL(Double Check Lock)单例模式:
public class Singleton {
    private static volatile Singleton instance;
 
    private Singleton() {}
 
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
  • 为什么使用 volatile:在 DCL 单例模式中,使用 volatile 关键字主要是为了防止指令重排序。在 Java 中,创建一个对象的过程可能会被重排序,这可能导致在多线程环境下,一个线程在获取到一个未完全初始化的实例时就开始使用它,从而导致程序出现错误。使用 volatile 关键字可以保证 instance 变量的可见性和禁止指令重排序,从而确保单例模式的正确性。
  • 枚举实现单例:
public enum EnumSingleton {
    INSTANCE;
 
    public void doSomething() {
        // 在这里实现单例的具体方法
    }
}

使用枚举实现单例模式是一种简洁且线程安全的方式,它利用了 Java 枚举类型的特性,保证了在任何情况下都只有一个实例存在,并且在类加载时就会初始化实例,避免了线程安全问题和懒加载的复杂性。

事件分发机制说一下,怎么写一个不能滑动的 ViewPager。

  • 事件分发机制:Android 的事件分发机制主要涉及三个重要方法:dispatchTouchEvent、onInterceptTouchEvent 和 onTouchEvent。当一个触摸事件发生时,事件会先传递到 Activity 的 dispatchTouchEvent 方法,然后依次传递到 ViewGroup 的 dispatchTouchEvent 方法和 onInterceptTouchEvent 方法,如果 ViewGroup 不拦截事件,则会继续传递到子 View 的 dispatchTouchEvent 方法和 onTouchEvent 方法。子 View 的 onTouchEvent 方法会根据自身的情况处理事件,如果子 View 处理了事件,则事件不再向上传递,否则事件会继续向上传递给父 View 的 onTouchEvent 方法,直到事件被处理或者传递到 Activity 的 onTouchEvent 方法。
  • 实现不能滑动的 ViewPager:要实现一个不能滑动的 ViewPager,可以通过自定义 ViewPager 并重写其 onInterceptTouchEvent 和 onTouchEvent 方法来实现。在 onInterceptTouchEvent 方法中,直接返回 false,表示不拦截触摸事件,让触摸事件直接传递给子 View。在 onTouchEvent 方法中,直接返回 true,表示消费触摸事件,不让事件继续向上传递,从而实现不能滑动的效果。以下是一个简单的示例代码:
public class NonSwipeableViewPager extends ViewPager {
 
    public NonSwipeableViewPager(Context context) {
        super(context);
    }
 
    public NonSwipeableViewPager(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
 
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return false;
    }
 
    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        return true;
    }
}

自定义 View 的基本流程说一下。

  • 自定义 View 的基本流程:
    • 继承 View 或其子类:根据需要选择合适的父类进行继承,如果只是简单的自定义视图,通常可以继承 View 类;如果是具有特定布局功能的视图,可以继承 ViewGroup 类或其子类。
    • 重写构造方法:通常需要重写至少一个构造方法,以便在创建视图时进行必要的初始化操作。在构造方法中,可以获取自定义属性的值、初始化画笔、设置背景等。
    • 测量视图大小:重写 onMeasure 方法,根据父视图传递的测量规格和自身的需求,计算出视图的测量宽度和高度。可以通过 MeasureSpec 类的静态方法来解析测量规格,并根据需要设置测量结果。
    • 布局视图位置:重写 onLayout 方法,在该方法中确定子视图的位置和大小。如果自定义视图是 ViewGroup 的子类,需要在该方法中遍历子视图,并调用子视图的 layout 方法来设置其位置和大小。
    • 绘制视图内容:重写 onDraw 方法,在该方法中使用画笔和画布进行图形绘制、文本绘制等操作,将自定义视图的内容绘制到画布上。
    • 处理触摸事件:根据需要重写 onTouchEvent 方法,处理触摸事件,如点击、滑动、长按等。可以根据触摸事件的类型和坐标,进行相应的逻辑处理,如改变视图的状态、触发动画等。
    • 提供对外接口:根据需要提供一些公共的方法和属性,以便外部代码能够与自定义视图进行交互,如设置视图的属性、获取视图的状态等。

RecyclerView 的缓存机制了解吗?

  • RecyclerView 的缓存机制:RecyclerView 的缓存机制主要是为了提高列表的滚动性能和减少内存占用。它采用了四级缓存结构,分别是:
    • mAttachedScrap 和 mChangedScrap:这是两个 ArrayList,用于缓存屏幕内可见的 ViewHolder。其中 mAttachedScrap 用于缓存未发生数据变化的 ViewHolder,mChangedScrap 用于缓存发生了数据变化但位置未改变的 ViewHolder。当 RecyclerView 滚动时,会优先从这两个缓存中获取 ViewHolder,避免了不必要的重新创建和绑定操作。
    • mCachedViews:这是一个 LinkedList,用于缓存最近从屏幕上移除的 ViewHolder。默认情况下,它最多可以缓存 2 个 ViewHolder。当 mAttachedScrap 和 mChangedScrap 中无法获取到合适的 ViewHolder 时,会从 mCachedViews 中获取。如果 mCachedViews 中的 ViewHolder 数量超过了最大缓存数量,会将最老的 ViewHolder 移除并放入到 mRecyclerPool 中。
    • mViewCacheExtension:这是一个自定义缓存扩展接口,开发者可以通过实现该接口来提供自己的缓存策略。RecyclerView 会在 mCachedViews 中无法获取到合适的 ViewHolder 时,调用该接口的 getViewForPositionAndType 方法来获取 ViewHolder。
    • mRecyclerPool:这是一个 ViewHolder 的缓存池,用于缓存不同类型的 ViewHolder。当 mCachedViews 中无法获取到合适的 ViewHolder 时,会从 mRecyclerPool 中获取。mRecyclerPool 中按照 ViewHolder 的类型进行分类缓存,每个类型的 ViewHolder 都有一个对应的 SparseArray 来存储。当从 mRecyclerPool 中获取到 ViewHolder 时,需要重新绑定数据。

二叉树的先序遍历怎么实现?如何得到一个二叉树的高度?如何遍历 View 树?

二叉树的先序遍历实现

先序遍历遵循根节点、左子树、右子树的顺序进行访问。对于二叉树的先序遍历,可以通过递归或者非递归(利用栈)的方式来实现。

递归实现:

class TreeNode {
    int val;
    TreeNode left;
    TreeNode right;
    TreeNode(int x) { val = x; }
}
 
public void preorderTraversal(TreeNode root) {
    if (root == null) {
        return;
    }
    System.out.println(root.val);  // 访问根节点
    preorderTraversal(root.left);  // 递归遍历左子树
    preorderTraversal(root.right);  // 递归遍历右子树
}

在递归实现中,先输出当前根节点的值,然后分别对左子树和右子树重复这个先访问根、再左子树、再右子树的过程,直到节点为空。

非递归实现(利用栈):

import java.util.Stack;
 
public void preorderTraversal(TreeNode root) {
    if (root == null) {
        return;
    }
    Stack<TreeNode> stack = new Stack<>();
    stack.push(root);
    while (!stack.isEmpty()) {
        TreeNode node = stack.pop();
        System.out.println(node.val);  // 访问弹出的节点(相当于根节点)
        if (node.right!= null) {
            stack.push(node.right);  // 先将右子树压入栈(因为后访问)
        }
        if (node.left!= null) {
            stack.push(node.left);  // 再将左子树压入栈
        }
    }
}

利用栈来模拟递归的调用栈,先将根节点入栈,然后循环弹出栈顶元素访问,再按右子树、左子树的顺序压入栈,保证后续能按先序遍历的顺序访问。

二叉树的高度获取

可以通过递归的方式来计算二叉树的高度。二叉树的高度定义为根节点到叶节点最长路径上的节点数。

public int getHeight(TreeNode root) {
    if (root == null) {
        return 0;
    }
    int leftHeight = getHeight(root.left);  // 计算左子树高度
    int rightHeight = getHeight(root.right);  // 计算右子树高度
    return Math.max(leftHeight, rightHeight) + 1;  // 返回左右子树中较高的高度加1(算上根节点这一层)
}

分别递归计算左子树和右子树的高度,然后取两者最大值再加 1 就是整棵二叉树的高度。

遍历 View 树

在 Android 中遍历 View 树可以从根视图开始,递归地访问子视图。例如,从 Activity 的根视图开始:

public void traverseViewTree(View rootView) {
    if (rootView == null) {
        return;
    }
    // 在这里可以对当前View进行操作,比如获取相关属性等
    System.out.println("当前View的类名: " + rootView.getClass().getName());
    if (rootView instanceof ViewGroup) {
        ViewGroup viewGroup = (ViewGroup) rootView;
        for (int i = 0; i < viewGroup.getChildCount(); i++) {
            View childView = viewGroup.getChildAt(i);
            traverseViewTree(childView);  // 递归遍历子视图
        }
    }
}

首先判断当前视图是否为空,然后可以对当前视图做一些操作(如打印相关信息等),如果当前视图是 ViewGroup 类型,就遍历它的子视图,继续递归调用遍历函数,这样就能遍历整个 View 树了。

手写快排算法。

手撕快速排序(作业帮)

快速排序是面试时考察最多的排序算法。快速排序(Quick Sort)是一种常用的排序算法,采用分治法(Divide and Conquer)进行排序。其基本思路是通过选择一个基准元素(pivot),将待排序的数组分成两部分,一部分所有元素都小于基准元素,另一部分所有元素都大于基准元素。然后递归地对这两部分继续进行排序,最终达到排序整个数组的效果。

快速排序的步骤:

  1. 选择基准元素:选择数组中的一个元素作为基准元素(常见的选择有第一个元素、最后一个元素、随机选择等)。
  2. 分区操作:将数组分成两部分,小于基准的放左边,大于基准的放右边。基准元素最终的位置已经确定。
  3. 递归排序:对基准元素左侧和右侧的子数组进行递归调用快速排序,直到子数组的大小为1或0,排序完成。

时间复杂度:

  • 最佳情况:O(n log n),发生在每次分割时都能平衡地分成两部分。
  • 最坏情况:O(n^2),当数组已经有序或反向有序时,每次选择的基准元素都可能是最小或最大的元素,从而导致不均匀的分割。
  • 平均情况:O(n log n),在大多数情况下,快速排序的时间复杂度表现良好。

空间复杂度:

  • 快速排序是原地排序,只需要 O(log n) 的栈空间来存储递归调用的状态。
  • 空间复杂度主要取决于递归的深度,最坏情况下是 O(n),但平均情况下是 O(log n)。

快速排序的Java实现代码:

public class QuickSort {
 
    // 主函数:调用快速排序
    public static void quickSort(int[] arr) {
        if (arr == null || arr.length < 2) {
            return;
        }
        quickSortHelper(arr, 0, arr.length - 1);
    }
 
    // 快速排序的核心递归函数
    private static void quickSortHelper(int[] arr, int left, int right) {
        if (left < right) {
            // 分区操作,返回基准元素的正确位置
            int pivotIndex = partition(arr, left, right);
            // 递归对基准元素左侧和右侧的子数组排序
            quickSortHelper(arr, left, pivotIndex - 1);
            quickSortHelper(arr, pivotIndex + 1, right);
        }
    }
 
    // 分区操作,返回基准元素的最终位置
    private static int partition(int[] arr, int left, int right) {
        // 选择最右边的元素作为基准元素
        int pivot = arr[right];
        int i = left - 1; // i 指向比基准小的元素区域的最后一个元素
        for (int j = left; j < right; j++) {
            if (arr[j] < pivot) {
                // 交换 arr[i + 1] 和 arr[j]
                i++;
                swap(arr, i, j);
            }
        }
        // 将基准元素放到正确位置
        swap(arr, i + 1, right);
        return i + 1; // 返回基准元素的索引
    }
 
    // 交换数组中两个元素的位置
    private static void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }
 
    // 主函数入口,打印排序后的结果
    public static void main(String[] args) {
        int[] arr = {5, 3, 8, 4, 6, 3, 2};
        System.out.println("Original array: ");
        printArray(arr);
        
        quickSort(arr);
 
        System.out.println("Sorted array: ");
        printArray(arr);
    }
 
    // 打印数组的辅助函数
    private static void printArray(int[] arr) {
        for (int num : arr) {
            System.out.print(num + " ");
        }
        System.out.println();
    }
}

为什么学 Android,怎么学的?

学习 Android 的原因

Android 作为目前全球使用极为广泛的移动操作系统,有着庞大的用户群体和丰富的应用场景。一方面,它为开发者提供了广阔的发挥空间,可以将各种创意和功能通过开发应用来实现,无论是社交、娱乐、办公还是生活服务等领域,都能通过 Android 应用来改善人们的生活体验。例如,开发一款便捷的出行规划应用,能帮助众多用户更高效地安排行程。另一方面,从就业角度来看,市场对于 Android 开发人才有着持续且较大的需求,掌握 Android 开发技能意味着能在移动开发领域找到合适的岗位,有不错的职业发展前景,能够参与到很多有影响力的项目开发中,实现自身的技术价值和职业价值。

学习的方式

最初开始学习 Android 时,是从基础的 Java 语言入手,因为 Android 开发主要基于 Java,掌握好 Java 的语法、面向对象的概念、数据结构等知识是打好基础的关键。通过阅读专业的 Java 编程书籍以及在线教程,进行大量的代码练习,比如完成一些简单的算法实现、控制台应用等。之后,开始正式接触 Android 开发,先学习 Android 的基础知识,如 Android 的四大组件(Activity、Service、Broadcast Receiver、Content Provider),通过官方文档去深入了解它们的功能、生命周期以及使用场景,同时跟着官方的示例代码动手实践,搭建简单的应用框架,实现一些基本功能,像创建一个可以显示文本和图片的 Activity 界面等。

随着学习的深入,开始学习各种 Android 的开源框架,例如学习使用 Retrofit 进行网络请求、Glide 加载图片等,通过查看它们的官方文档、GitHub 上的源码以及一些技术博客的讲解,掌握它们的使用方法和原理。在遇到问题时,积极在技术论坛如 Stack Overflow 上查找相似的问题和解决方案,也会和身边同样学习 Android 开发的同学或者开发者社区里的同行交流探讨,分享学习心得和开发经验,通过实际项目的开发不断巩固和提升自己的 Android 开发能力。

MVP 模式和 MVC 模式的优缺点是什么?

MVC 模式

  • 优点:
    • 结构清晰:将应用分为模型(Model)、视图(View)和控制器(Controller)三个部分,各自职责明确。模型负责数据的存储和处理,视图负责展示界面,控制器则负责协调模型和视图之间的交互,这种分工使得代码结构易于理解,对于小型项目来说,开发和维护相对简单直观。
    • 可复用性:模型层可以独立于视图和控制器进行开发,只要接口定义清晰,模型层的代码能够在不同的项目或者不同的视图场景下复用,提高了代码的复用效率。
    • 便于团队协作:不同的开发人员可以专注于不同的层次,比如后端开发人员负责模型层的开发,前端开发人员负责视图层的开发,而控制器层可以由熟悉业务逻辑的开发人员来完成,有利于提高团队开发的效率。
  • 缺点:
    • 耦合性问题:随着项目规模的增大,视图层和控制器层往往会存在较强的耦合,视图层的变动可能会导致控制器层的大量修改,反之亦然,不利于代码的扩展和维护。
    • 单元测试困难:由于视图和控制器之间的紧密耦合,以及控制器对视图的直接操作,使得对控制器进行单元测试时,很难模拟出真实的视图环境,增加了单元测试的难度,不利于保证代码的质量。
    • 不利于复杂业务逻辑处理:在处理复杂的业务逻辑时,控制器层可能会变得臃肿,大量的业务逻辑代码堆积在控制器中,导致代码的可读性和可维护性变差。

MVP 模式

  • 优点:
    • 解耦性强:通过引入 Presenter 层,将视图层和模型层进行了有效的解耦,视图层只负责展示界面和接收用户的交互事件,模型层专注于数据的处理和存储,Presenter 层则负责协调两者之间的交互,使得各层之间的依赖关系更加清晰,某一层的变动对其他层的影响较小,便于代码的扩展和维护。
    • 便于单元测试:由于视图层和模型层的交互都通过 Presenter 层来进行,在进行单元测试时,可以很方便地对 Presenter 层进行测试,通过模拟视图层和模型层的接口,独立地验证业务逻辑的正确性,提高了代码的质量保证。
    • 代码可读性和可维护性好:在处理复杂的业务逻辑时,Presenter 层可以将相关的逻辑进行集中处理,避免了像 MVC 模式中控制器层那样代码过于臃肿的情况,使得代码结构更加清晰,可读性和可维护性得到提升。
  • 缺点:
    • 代码量增加:相较于 MVC 模式,MVP 模式多了一层 Presenter,对于简单的项目来说,可能会导致代码量有所增加,开发成本相对提高,需要编写更多的接口和实现类来完成各层之间的交互。
    • 学习成本较高:对于初学者或者刚接触这种设计模式的开发人员来说,需要理解和掌握 MVP 模式各层之间的交互关系以及数据流向,相比于直观的 MVC 模式,学习成本会更高一些,上手难度稍大。

MVP 模式中 M 和 V 如何直接通信,不经过 P 层?

在标准的 MVP 模式设计理念中,模型(M)和视图(V)是不应该直接通信的,而是通过 Presenter(P)层来进行交互协调,这样才能保证各层之间的解耦以及整个架构的清晰性和可维护性。

不过,在一些特殊的、非严格遵循标准架构的场景下,如果想让 M 和 V 直接通信,一种可能的方式是通过注册回调的形式。例如,模型层在数据发生变化时,可以提供一个数据变化的监听器接口,视图层实现这个接口并注册到模型层。当模型层的数据更新后,通过遍历已注册的监听器列表,调用相应的回调方法来通知视图层进行更新,类似如下代码结构:

模型层(Model)

import java.util.ArrayList;
import java.util.List;
 
public class UserModel {
    private List<UserDataChangeListener> dataChangeListeners = new ArrayList<>();
    private String userInfo;
 
    public void setUserInfo(String info) {
        this.userInfo = info;
        notifyDataChange();  // 数据改变时通知监听者
    }
 
    public String getUserInfo() {
        return userInfo;
    }
 
    public void registerDataChangeListener(UserDataChangeListener listener) {
        dataChangeListeners.add(listener);
    }
 
    public void unregisterDataChangeListener(UserDataChangeListener listener) {
        dataChangeListeners.remove(listener);
    }
 
    private void notifyDataChange() {
        for (UserDataChangeListener listener : dataChangeListeners) {
            listener.onDataChanged(userInfo);
        }
    }
 
    public interface UserDataChangeListener {
        void onDataChanged(String newData);
    }
}

视图层(View)

public class UserProfileView implements UserModel.UserDataChangeListener {
    private UserModel userModel;
 
    public UserProfileView(UserModel model) {
        this.userModel = model;
        userModel.registerDataChangeListener(this);  // 注册到模型层
    }
 
    @Override
    public void onDataChanged(String newData) {
        // 在这里根据新数据更新视图显示,比如更新文本框显示用户信息等
        System.out.println("视图层接收到新数据,更新显示: " + newData);
    }
}

但这种方式其实违背了 MVP 模式的初衷,会使得各层之间的耦合性增强,不利于大型项目的代码维护和扩展,所以在实际应用中还是建议严格按照标准的 MVP 架构,通过 Presenter 层来进行 M 和 V 之间的交互沟通。

一个线程能有几个 handler,几个 looper?

在 Android 中,一个线程可以有多个 Handler 对象。Handler 主要用于在不同线程间传递消息、处理消息以及进行一些异步任务的调度等。比如在主线程中,我们可以创建多个 Handler 实例来分别处理不同模块或者不同类型的消息,像一个 Handler 用于处理 UI 更新相关消息,另一个 Handler 用于处理网络请求返回结果相关消息等,只要合理地进行设计和使用,根据业务需求可以创建多个 Handler 来满足各种情况。

而一个线程通常只有一个 Looper 对象,Looper 的作用是不断地从消息队列(MessageQueue)中取出消息,并将消息分发给对应的 Handler 去处理。它会开启一个循环,不断地进行消息的检索和分发工作。从线程的运行机制来讲,为了保证消息处理的顺序和稳定性,一个线程对应一个 Looper 来管理消息队列是比较合理的设计。如果有多个 Looper,可能会导致消息处理的混乱,不知道该将消息分发到哪个 Looper 对应的消息队列中,也难以协调不同 Looper 之间的工作关系,所以在常规情况下,一个线程只会关联一个 Looper 来维持正常的消息处理流程。

能在子线程中创建 handler 吗,如何创建?

可以在子线程中创建 Handler,不过需要先为该子线程创建 Looper 对象,因为 Handler 的工作依赖于 Looper 以及其对应的消息队列。以下是在子线程中创建 Handler 的常规步骤:

首先,在子线程中通过调用 Looper.prepare () 方法来初始化一个 Looper 对象,这个方法会创建一个新的消息队列,并将其与当前要创建的 Looper 关联起来,示例如下:

new Thread(new Runnable() {
    @Override
    public void run() {
        Looper.prepare();
        // 在这里可以进行后续的Handler创建等操作
    }
}).start();

然后,创建 Handler 对象,在创建 Handler 时,可以重写 handleMessage 方法来定义收到消息后的具体处理逻辑,例如:

Handler handler = new Handler() {
    @Override
    public void handleMessage(Message msg) {
        switch (msg.what) {
            case 1:
                // 处理消息类型为1的情况,比如进行一些数据处理、UI更新(如果有权限的话)等操作
                break;
            default:
                break;
        }
    }
};

最后,要记得调用 Looper.loop () 方法来启动 Looper 的消息循环,让它开始不断地从消息队列中取出消息并分发给对应的 Handler 处理,代码如下:

Looper.loop();

这样,在子线程中创建的 Handler 就能正常工作了,它可以接收并处理来自其他线程发送过来的消息,实现线程间的通信和异步任务的处理等功能。

如何防止多次点击?如果不用 singleTop 如何实现?

防止多次点击是为了避免用户在短时间内频繁操作而导致一些不必要的重复行为或者异常情况,常见的实现方式有以下几种:

利用时间戳判断

可以记录用户上一次点击的时间戳,当用户再次点击时,获取当前时间戳,然后与上一次的时间戳进行对比,如果时间间隔小于设定的阈值(比如 500 毫秒),则判定为重复点击,不执行相应的操作,示例代码如下:

private long lastClickTime = 0;
 
public void onClick(View v) {
    long currentTime = System.currentTimeMillis();
    if (currentTime - lastClickTime > 500) {
        lastClickTime = currentTime;
        // 执行正常的点击操作,比如打开新页面、提交表单等
    } else {
        // 提示用户不要频繁点击或者直接忽略此次点击
    }
}

通过这种时间差的判断,能有效过滤掉短时间内的多次点击行为。

使用 RxJava 实现

借助 RxJava 的操作符,可以更优雅地实现防止多次点击的功能。例如,利用 throttleFirst 操作符,它可以在一定时间间隔内只允许第一次点击事件通过,代码如下:

RxView.clicks(view)
      .throttleFirst(500, TimeUnit.MILLISECONDS)
      .subscribe(new Consumer<Object>() {
            @Override
            public void accept(Object o) {
                // 执行点击操作
            }
        });

这里定义了 500 毫秒的时间间隔,在这个间隔内只有第一次点击会触发后续的操作,其他点击会被忽略。

如果不用 singleTop 这种 Activity 启动模式来防止多次点击(比如在多个不同 Activity 之间切换点击的场景),可以在每个 Activity 的点击事件处理逻辑中采用上述的时间戳判断或者 RxJava 等方式进行控制。另外,还可以通过设置一个全局的点击状态管理类,记录当前应用内各个关键点击操作的状态和时间,在每次点击时统一进行判断和过滤,确保不会出现因频繁点击导致的异常情况,保障应用的正常运行和良好的用户体验。

如何记录时间?如果用户两次点击之间修改了系统时间,使得两次点击时间的差值为负数怎么办?

记录时间的方式

在 Android 开发中,常用的记录时间的方法有以下几种:

使用 System.currentTimeMillis ()

这是最常用的获取当前时间戳的方法,它返回从 1970 年 1 月 1 日 00:00:00 UTC 到当前时刻的毫秒数。例如,在记录某个操作开始和结束的时间,以计算操作耗时的时候可以这样使用:

long startTime = System.currentTimeMillis();
// 执行一些操作,比如进行网络下载、文件读取等
long endTime = System.currentTimeMillis();
long duration = endTime - startTime;
System.out.println("操作耗时: " + duration + "毫秒");

通过获取操作前后的时间戳差值,就能准确地知道操作所花费的时间。

使用 SystemClock 类

SystemClock 类提供了一些与系统时钟相关的实用方法。例如,SystemClock.elapsedRealtime () 可以获取从系统启动后到现在所经过的时间(包含设备休眠的时间),单位是毫秒,常用于记录某个应用内的相对时间,像统计某个功能模块从打开到关闭所经历的时间等,代码如下:

long startTime = SystemClock.elapsedRealtime();
// 执行功能模块相关的操作
long endTime = SystemClock.elapsedRealtime();
long duration = endTime - startTime;
System.out.println("功能模块运行耗时: " + duration + "毫秒");

另外,SystemClock.uptimeMillis () 获取的是从系统启动后到现在所经过的时间,但不包含设备休眠的时间,适用于一些需要排除设备休眠影响的时间记录场景。

应对系统时间修改导致时间差值为负数的情况

当用户两次点击之间修改了系统时间,使得两次点击时间的差值为负数时,单纯依靠常规的时间戳相减来判断时间间隔就不准确了。为了避免这种情况对时间记录和相关逻辑的影响,可以采用相对时间记录的方式。

比如使用 SystemClock.elapsedRealtime () 来记录时间,因为它是基于系统启动后的相对时间,不受系统时间手动修改的影响。假设在记录点击时间间隔时,第一次点击记录下 SystemClock.elapsedRealtime () 的值为 startTime,第二次点击记录下的值为 endTime,那么时间间隔就是 endTime - startTime,这个差值始终能正确反映两次点击之间实际经过的时间,不会因为系统时间的修改而出现负数等不合理的情况。

另外,也可以在应用内维护一个自己的时间计数逻辑,比如定义一个初始时间值,然后通过一个定时器(比如利用 Handler 的定时发送消息功能或者 Android 的 AlarmManager 等)按照固定的时间间隔去递增这个时间值,每次记录点击时间时都以这个自定义的时间值为准,这样也能避免系统时间修改带来的干扰,确保时间记录的准确性和相关业务逻辑的正常运行。

说说对插件化的原理,资源的插件化 id 重复如何解决?

插件化原理

插件化是一种用于扩展 Android 应用功能的技术,它的核心原理主要涉及以下几个方面:

类加载机制的利用

Android 系统默认使用 PathClassLoader 来加载应用的主 dex 文件以及相关的类,而插件化则通过自定义类加载器(如 DexClassLoader)来加载外部的插件 dex 文件。通过打破常规的类加载路径限制,实现对插件中类的加载,使得插件中的类能够在主应用运行时被识别和使用。例如,在插件开发完成后,将插件打包成包含 dex 文件的压缩包,然后在主应用中利用 DexClassLoader 指定插件的路径,按照双亲委派模型的规则,让自定义类加载器去加载插件中的类,这样就能动态地将插件中的代码融入到主应用的运行环境中。

组件的生命周期管理

对于 Activity、Service 等组件,插件化需要解决它们在插件中的启动、暂停、销毁等生命周期管理问题。通常是通过代理的方式,在主应用中创建代理组件来模拟真实组件的行为,然后在合适的时机将相关的生命周期调用转发给插件中的对应组件。比如对于插件中的 Activity,主应用中的代理 Activity 会接收系统的启动等事件,然后通过一些反射和通信机制,让插件中的 Activity 执行相应的生命周期方法,从而实现插件组件在主应用环境下正常地参与到应用的运行流程中。

资源的加载与管理

插件中的资源(如图片、布局、字符串等)不能直接被主应用使用,需要通过资源加载机制的改造来实现。一般会创建一个资源加载器,将插件中的资源进行解析并整合到主应用的资源体系中,使得插件资源可以像主应用资源一样被访问和使用。例如,通过 AssetManager 的相关方法,将插件中的资源文件进行加载,然后与主应用的资源进行合并或者区分管理,以满足在不同场景下对插件资源的调用需求。

资源的插件化 id 重复如何解决

在插件化过程中,当出现资源的插件化 id 重复问题时,可以采用以下几种解决办法:

资源包名隔离

给不同的插件分配不同的资源包名,这样即使存在相同的资源 id,由于资源的查找是基于包名空间的,在主应用或者插件内部进行资源查找时,就能通过包名来区分不同来源的资源,避免了 id 重复导致的冲突。例如,插件 A 使用的资源包名为 "com.plugin.a",插件 B 使用的资源包名为 "com.plugin.b",在加载和使用资源时,系统会根据包名准确地定位到对应的资源,即使它们有相同的资源 id 也不会混淆。

资源 id 重映射

在插件打包或者加载阶段,对插件中的资源 id 进行重新映射,通过一定的算法或者规则,将插件中原有的资源 id 转换为新的、在整个应用环境下唯一的 id。可以建立一个映射表,记录插件中原始资源 id 和新 id 之间的对应关系,在使用插件资源时,先通过映射表进行转换,然后再去查找对应的资源。这样就能保证在主应用和多个插件组成的整体环境中,不会出现因资源 id 重复而引发的资源获取和使用错误,确保插件化应用中资源管理的准确性和稳定性。

资源合并时的冲突处理

当把插件资源合并到主应用资源体系中时,可以进行冲突检测和处理。对于重复 id 的资源,可以根据一定的策略进行选择保留或者合并,比如优先保留主应用中的资源,或者根据资源的类型、版本等因素来决定保留哪一个资源。同时,在合并过程中,可以记录下所有的资源 id 使用情况和冲突信息,方便在出现问题时进行排查和调整,保障资源整合的顺利进行以及应用运行过程中资源使用的正确性。

mvp 与 mvvm 模式的区别是什么?

MVP 模式

  • 结构方面:MVP 分为 Model(模型)、View(视图)和 Presenter(展示器)三层。Model 负责数据的存储、获取以及业务逻辑处理等,比如从数据库获取用户信息或者进行复杂的运算等。View 则专注于界面的展示以及用户交互的响应,像显示文本、图片,接收用户点击等操作。Presenter 起着桥梁作用,它持有对 View 和 Model 的引用,协调两者之间的交互,将 Model 处理好的数据传递给 View 进行展示,同时把 View 层的用户操作反馈给 Model 进行相应处理。
  • 通信方式:View 和 Model 之间不能直接通信,必须通过 Presenter 来中转传递信息,这样的设计使得各层职责明确,耦合性相对较低,但也增加了一定的代码量,因为要写较多接口以及实现类来保障各层交互顺畅。
  • 对 UI 更新影响:Presenter 层处理完业务逻辑后,需要主动调用 View 层的接口方法来更新 UI,这意味着开发人员要手动去控制 UI 更新时机和内容,如果处理不当,容易出现 UI 更新不及时或者出现逻辑错误等情况。

MVVM 模式

  • 结构方面:MVVM 由 Model(模型)、View(视图)和 ViewModel(视图模型)构成。Model 依旧承担数据存储、业务逻辑等基础功能。View 同样负责界面展示与用户交互。ViewModel 则是核心的中间层,它不仅包含了业务逻辑处理,还对数据进行了加工处理,将其转化为适合 View 层直接绑定展示的数据形式,例如将原始的用户数据格式化为适合在界面文本框中显示的字符串等。
  • 通信方式:View 和 ViewModel 之间通过数据绑定机制进行通信,比如在 Android 中可以利用 Data Binding 库或者 LiveData 等实现双向数据绑定。View 层的控件可以自动根据 ViewModel 中的数据变化进行更新,同时用户在 View 层的操作也能自动反馈到 ViewModel 中,减少了大量手动更新 UI 的代码,代码简洁性上有优势。
  • 对 UI 更新影响:基于数据绑定,只要 ViewModel 中的数据发生改变,与之绑定的 View 层控件会自动更新显示内容,无需像 MVP 那样手动调用方法去更新 UI,开发效率更高,也更不容易出现 UI 更新不一致的问题。

总体来说,MVP 更强调各层的解耦和明确分工,代码结构相对清晰但略显繁琐;MVVM 借助数据绑定机制在 UI 更新和开发便捷性方面表现出色,不过对于初学者理解数据绑定原理等可能有一定难度。

JetPack 组件用过哪些?lifeCycle 的原理是什么?如果在 onStart 里面订阅,会回调 onCreate 吗?

用过的 JetPack 组件

在 Android 开发中使用过多个 JetPack 组件,比如:

  • Navigation:它能够帮助构建复杂的导航结构,无论是单 Activity 多 Fragment 的架构还是多 Activity 的应用,都可以通过它方便地实现页面之间的跳转以及传递参数等功能。例如在一个电商应用中,方便地实现从商品列表页到商品详情页、再到购物车页等不同页面的流畅跳转,并且可以清晰地管理导航栈,处理返回操作等逻辑。
  • Room:这是一个强大的数据库访问层框架,基于 SQLite,简化了数据库的操作。它通过定义实体类、DAO(数据访问对象)等,让数据库的增删改查操作变得十分简洁,而且支持编译时的 SQL 语句检查,减少了运行时出现 SQL 语法错误的风险,像在一个记录用户笔记的应用中,使用 Room 可以高效地存储和读取用户的笔记内容。
  • LiveData:主要用于在数据发生变化时通知观察者,并且能感知组件的生命周期,避免内存泄漏等问题。在构建响应式 UI 时非常有用,比如在一个实时显示股票价格的应用中,当后台获取到新的股票价格数据时,通过 LiveData 通知 UI 层进行更新展示。

Lifecycle 的原理

Lifecycle 是 JetPack 中用于管理组件生命周期的一个重要组件。它通过在 Activity、Fragment 等具有生命周期的组件中添加 LifecycleOwner 接口的实现,来对外提供生命周期的状态信息。

在内部,它有一个 LifecycleRegistry 类作为核心,这个类维护着当前组件的生命周期状态,并且有一系列的状态转换方法,比如从 CREATED 状态转换到 STARTED 状态等。当组件的生命周期方法被系统调用时(比如 Activity 的 onCreate、onStart 等方法),对应的 LifecycleRegistry 会相应地更新内部的生命周期状态,同时会通知已注册的 LifecycleObserver(观察者)。

LifecycleObserver 可以是实现了默认方法的接口或者是继承自特定的抽象类,它们通过注解(如 @OnLifecycleEvent 注解)来标记在对应的生命周期阶段要执行的方法,当生命周期状态发生变化时,LifecycleRegistry 就会根据状态去调用这些标记好的方法,从而让观察者能感知到组件的生命周期变化,实现一些和生命周期相关的逻辑处理,比如在组件启动时初始化资源,在组件销毁时释放资源等。

在 onStart 里面订阅,会回调 onCreate 吗?

如果在 onStart 方法里面订阅了一个基于 Lifecycle 的观察者(比如使用 LiveData 进行订阅观察),正常情况下是不会回调 onCreate 方法的。因为生命周期是按照顺序依次从 CREATED(对应 onCreate 结束后)到 STARTED(对应 onStart 结束后)等状态进行转换的,当已经进入到 onStart 阶段,意味着 onCreate 阶段已经结束,且之前没有订阅就不会触发相关的回调。

不过,如果是使用了一些特殊的机制或者自定义的逻辑,故意去模拟或者重新触发生命周期状态的变化,理论上可以使得看起来好像回调了 onCreate,但这并不是正常的生命周期流程下会出现的情况。在标准的 Android 生命周期机制以及遵循 Lifecycle 组件正常使用的场景下,在 onStart 里面订阅不会导致 onCreate 被回调。

单例模式有什么缺点?

内存泄漏风险

在单例模式中,如果单例对象持有了 Activity 或者其他有生命周期的组件的引用,并且没有合理地处理这个引用关系,当该组件需要被销毁时(比如 Activity 执行了 onDestroy 方法),由于单例对象一直存在,可能会导致被引用的组件无法被垃圾回收,从而造成内存泄漏。例如,一个单例的网络请求管理类,在进行网络请求时为了方便回调处理结果,持有了发起请求的 Activity 的引用,如果没有在合适的时候释放这个引用,当 Activity 关闭后,该 Activity 占用的内存依然无法释放,长时间积累可能使应用出现内存不足等问题。

多线程并发问题

在多线程环境下,如果单例模式实现不当,可能会出现创建多个实例的情况,违背了单例模式确保只有一个实例的初衷。比如在简单的懒汉式单例实现中(没有使用合适的同步机制),多个线程同时判断单例对象是否已经创建,可能都认为还没创建,进而都去创建实例,导致出现多个实例共存。即使采用了像 DCL(Double Check Lock)这样的机制,如果没有正确使用 volatile 关键字来防止指令重排序,也可能出现线程获取到未完全初始化的实例,导致程序出现错误。

可测试性受限

单例模式使得类的实例在整个应用生命周期内是唯一的,这在进行单元测试时会带来困难。因为很难模拟出不同状态的单例实例,而且单例对象的状态可能会被应用的其他部分随意修改,导致测试环境难以控制。例如在测试一个依赖单例配置类的功能模块时,由于单例配置类在整个应用中只有一个实例,很难单独为测试用例去设置特定的配置参数,影响了测试的准确性和全面性,不利于保证代码的质量。

代码耦合度增加

由于单例对象在全局可访问,很多地方可能都会直接依赖这个单例进行操作,导致代码之间的耦合度变高。一旦单例的实现或者内部逻辑需要改变,可能会波及到众多使用该单例的代码模块,使得代码的维护和扩展变得复杂。比如一个全局的单例日志记录类,很多业务模块都调用它来记录日志,若要修改日志记录的格式或者存储方式等内部逻辑,就需要去排查和修改所有调用该单例的地方,增加了开发成本和出错的概率。

说说 App 的启动过程,在 ActivityThread 的 main 方法里面做了什么事,什么时候启动第一个 Activity?

App 的启动过程

  • 点击启动阶段:当用户点击应用图标时,系统会首先检测应用是否已经安装,然后从存储中找到对应的 APK 文件,开始进行一系列的初始化和加载操作。
  • Zygote 进程 fork 操作:Android 系统通过 Zygote 进程来孵化应用进程,Zygote 进程在启动时会预加载很多常用的类、资源等,当要启动应用时,系统会从 Zygote 进程中 fork 出一个新的应用进程,这个新进程就会继承 Zygote 中已有的资源,加快启动速度,并且有自己独立的内存空间等。
  • Application 创建与初始化:在新的应用进程中,首先会创建 Application 对象,调用其 onCreate 方法,在这个阶段可以进行一些全局的初始化工作,比如初始化第三方库、配置全局的参数等,这个过程是对整个应用运行环境的基础搭建。

ActivityThread 的 main 方法里面做的事

  • Looper 准备与消息循环启动:ActivityThread 的 main 方法是应用的主线程入口,在这里首先会调用 Looper.prepareMainLooper () 方法来创建并准备好主线程的 Looper 对象,这个 Looper 用于处理主线程中的各种消息。然后调用 Looper.loop () 方法,开启消息循环,使得主线程能够不断接收并处理各种消息,比如来自系统或者应用自身的 UI 更新消息、事件响应消息等,维持应用的正常运行状态。
  • 创建并启动 Application:在 main 方法中,会通过一系列的调用去创建 Application 对象,并且执行其 onCreate 方法,完成对 Application 的初始化,如前面所说,这一步骤对于整个应用的资源初始化、第三方库配置等有着关键作用。
  • ActivityThread 自身的初始化:会进行 ActivityThread 自身的一些内部结构和状态的初始化,比如初始化与系统进行交互的相关机制、设置一些默认的参数等,确保 ActivityThread 能够顺利地协调应用内各个组件以及与外部系统之间的关系,保障后续的 Activity、Service 等组件能够正常地启动、运行和管理。

启动第一个 Activity 的时间

在完成了 Application 的创建和初始化,以及 ActivityThread 的相关准备工作后,当系统或者应用自身有启动 Activity 的请求时(比如通过 Intent 来指定要启动的 Activity),就会开始启动第一个 Activity。这个过程涉及到 Activity 的加载、创建实例、调用其生命周期方法等操作。首先会加载 Activity 的类,然后创建 Activity 对象,接着依次调用 onCreate、onStart、onResume 等生命周期方法,使得 Activity 的界面能够展示给用户,用户可以开始与之进行交互,至此第一个 Activity 成功启动并进入运行状态,整个应用也就正式开始面向用户提供服务了。

说说对 Handler 机制的了解,同步消息,异步消息等。

Handler 机制概述

Handler 机制是 Android 中用于实现线程间通信以及进行异步任务处理的一套重要机制。它主要由 Handler、Looper、MessageQueue 这几个核心部分组成。

  • Handler:它是开发者直接操作的对象,用于发送消息(Message)以及处理消息。可以在不同的线程中创建 Handler 实例,在一个线程中创建的 Handler 可以将消息发送到与之关联的 Looper 所在的线程中进行处理,通常在主线程创建 Handler 用于更新 UI 等操作,因为只有主线程才能直接操作 UI 组件。
  • Looper:每个线程可以有一个 Looper 对象,它内部维护着一个 MessageQueue(消息队列),并且开启一个循环,不断地从消息队列中取出消息,然后分发给对应的 Handler 去处理,从而保证消息能够按照顺序被处理,维持了线程内消息处理的秩序。
  • MessageQueue:这是一个消息的队列,用于存储等待被处理的消息,消息以队列的形式进行排列,遵循先进先出的原则,新的消息会被添加到队列末尾,Looper 会从队列头部依次取出消息进行处理。

同步消息

  • 定义与特点:同步消息是指按照正常的消息队列顺序依次进行处理的消息。当消息被发送到 MessageQueue 后,它会等待前面的消息都处理完,轮到自己时才会被 Looper 取出,然后交给对应的 Handler 的 handleMessage 方法进行处理。例如,在一个依次下载多个文件的场景中,通过发送同步消息来控制下载任务的顺序,每个下载任务对应的消息按照先后顺序在消息队列中排队,前一个文件下载完成对应的消息处理完后,才会处理下一个文件下载对应的消息,确保了任务执行的顺序性。
  • 处理流程:发送同步消息时,一般通过 Handler 的 sendMessage 方法发送,消息进入 MessageQueue 后,Looper 在循环中不断检查消息队列头部的消息,判断是否到了处理该消息的时间(比如延迟消息要等延迟时间到了),如果是同步消息且前面没有其他未处理的消息了,就将其取出交给对应的 Handler 进行处理,处理完后再接着处理下一个消息,整个过程是有序且连贯的,不会出现插队等情况。

异步消息

  • 定义与特点:异步消息相对同步消息而言,它在处理时可以不受常规消息队列顺序的限制,具有一定的 “插队” 特性。通常用于一些需要优先处理或者不受其他常规任务顺序影响的情况,比如在界面上实时显示一些重要的系统通知消息,即便当前正在处理其他普通的 UI 更新消息,这些重要通知对应的异步消息也能及时被处理并展示出来。
  • 处理流程:要发送异步消息,首先需要通过设置消息的属性或者利用一些特殊的机制来标记消息为异步消息。比如可以在创建 Message 对象后,设置其 setAsynchronous (true) 来将其标记为异步消息,然后再通过 Handler 发送。在 Looper 处理消息时,会区分同步消息和异步消息,对于异步消息,如果满足一定的触发条件(比如已经到了可以处理的时间等),会优先将其从消息队列中取出进行处理,而不一定非要等待前面的同步消息都处理完,不过在内部也是按照一定的规则来保障整体消息处理的合理性和稳定性,避免出现混乱的情况。

此外,Handler 机制还涉及到与线程、消息屏障等方面的关联,比如通过设置消息屏障可以暂停同步消息的处理,优先处理异步消息,然后再恢复同步消息的处理等,通过这些灵活的机制,能够满足 Android 开发中复杂多样的线程间通信和任务处理需求。

说说对屏幕刷新机制的了解,双重缓冲,三重缓冲,黄油模型。

屏幕刷新机制概述

在 Android 系统中,屏幕刷新机制是保障屏幕内容能够流畅、准确显示的关键。手机屏幕通常以固定的帧率(如 60Hz,意味着每秒刷新 60 次)进行刷新,每次刷新都需要将新的画面数据从内存传递到屏幕上进行显示。系统会有一个垂直同步信号(VSync)来协调这个过程,当接收到 VSync 信号时,就意味着可以开始新一帧画面的绘制与显示了。

应用层的 UI 绘制工作一般先在 CPU 进行布局、测量、绘制等操作,生成对应的图形数据,然后将这些数据传递给 GPU 进行渲染,渲染后的图像数据会被放入到缓冲区中,等待合适时机传递给屏幕显示。整个过程需要各环节紧密配合,确保在每一帧的时间内完成,以维持画面的流畅性。

双重缓冲

双重缓冲是一种为了避免画面闪烁、提高绘制效率的技术。它主要是使用两个缓冲区,一个是前台缓冲区,用于直接和屏幕对接,当前显示在屏幕上的画面数据就存储在这里;另一个是后台缓冲区,应用在进行 UI 绘制时,会先在后台缓冲区进行操作,比如绘制新的视图、更新界面元素等,当绘制完成后,再将后台缓冲区的内容快速切换到前台缓冲区,替换掉原来的画面,这样就能让用户看到更新后的界面,而且避免了在绘制过程中直接对正在显示的画面造成影响而出现闪烁等问题。

例如在绘制一个复杂的游戏场景或者动画效果时,在后台缓冲区把所有元素的新位置、新状态等都绘制好,然后瞬间切换到前台缓冲区展示给用户,使得画面切换过渡很平滑。

三重缓冲

三重缓冲是在双重缓冲基础上进一步的优化。除了前台缓冲区和后台缓冲区外,额外增加了一个缓冲区。当后台缓冲区正在被应用用于绘制下一帧画面时,如果此时又有新的绘制任务需要处理(比如动画更新频繁等情况),就可以将新的绘制内容先放到新增的这个缓冲区中,这样可以避免因为后台缓冲区还在使用中而出现等待的情况,提高了整体的绘制效率,让画面的更新更加及时、流畅,尤其适用于对帧率要求较高、画面变化频繁的场景,像高帧率的游戏画面展示等场景下优势明显。

黄油模型

黄油模型(Project Butter)是 Android 系统为了提升屏幕绘制的流畅度和响应速度而提出的一套优化机制。它重点围绕着 VSync 信号来进行优化整合,确保了应用的 UI 绘制、渲染以及屏幕显示等环节都能和 VSync 信号精准同步,让每一帧画面的处理都能在规定的时间内有序完成,减少了画面卡顿、掉帧等现象。

通过对系统底层的调度优化,比如更合理地安排 CPU、GPU 的工作时间,协调好各组件之间的交互,让触摸事件的响应、UI 的更新绘制等都能紧密配合屏幕刷新节奏,给用户带来如黄油般顺滑的操作体验,像在快速滑动列表、进行复杂动画交互等场景下,用户能感受到更加流畅的视觉和操作效果。

onCreate、onResume、onStart 里面,什么地方可以获得宽高?

在 Android 的 Activity 生命周期方法中,onCreate、onStart、onResume 这几个方法获取视图宽高的情况各有不同。

在 onCreate 方法中

一般情况下,直接在 onCreate 方法里获取视图的准确宽高是不太可靠的。因为在 onCreate 阶段,视图的测量、布局等流程可能还没有完全结束,视图的大小可能还没有最终确定下来。虽然可以通过调用 View 的 getWidth 和 getHeight 方法尝试获取,但往往得到的值可能是不准确的,比如可能是默认的初始值或者是上一次布局时残留的值等,并非真实反映当前界面布局完成后的宽高数值。

不过,如果是已经明确知道视图的宽高是固定值(例如通过设置了明确的 dp 数值,且不受父容器等因素影响),那么可以获取到设定好的固定宽高值,但对于大多数动态布局或者需要根据屏幕大小、父容器大小等因素来确定宽高的情况,在此阶段获取的宽高通常不符合预期。

在 onStart 方法中

类似 onCreate 阶段,onStart 时视图的布局过程也未必彻底完成,视图可能还在等待进一步的调整和确定最终尺寸,所以同样不能确保获取到准确的宽高。此时视图已经处于可见的前期阶段,但从整个布局流程来看,还处于过渡状态,尝试获取宽高可能得到不准确的结果,存在和 onCreate 阶段类似的问题,无法准确反映最终在屏幕上展示时的实际宽高情况。

在 onResume 方法中

在 onResume 阶段,相对前面两个方法,获取宽高的可靠性有所提高。因为到了 onResume 时,Activity 已经完全可见并且即将可以与用户进行交互,视图的大部分布局流程基本已经完成,对于一些简单的布局结构或者固定尺寸的视图,此时有较大概率能获取到相对准确的宽高。

但对于一些复杂的、依赖异步数据或者需要等待某些资源加载完成后才确定最终尺寸的视图(比如视图内容是根据网络图片加载后自适应显示大小的情况),可能还是无法准确获取到宽高,需要进一步通过其他机制(如 View 的 ViewTreeObserver 等)来监听视图尺寸的真正确定时刻,然后获取准确的宽高数值。

不过,有一种相对可靠的通用做法是通过 View 的 ViewTreeObserver 来添加一个 OnGlobalLayoutListener 监听器,在这个监听器的回调方法里获取宽高,无论在 onCreate、onStart 还是 onResume 阶段添加这个监听器,只要视图的布局最终确定下来,都会触发回调,从而可以准确获取到宽高,示例如下:

public class MainActivity extends AppCompatActivity {
    private View targetView;
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
 
        targetView = findViewById(R.id.target_view);
 
        ViewTreeObserver observer = targetView.getViewTreeObserver();
        observer.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                int width = targetView.getWidth();
                int height = targetView.getHeight();
                // 在这里可以使用获取到的宽高进行后续操作
                targetView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
            }
        });
    }
}

为什么 view.post 可以获得宽高,有看过 view.post 的源码吗?

为什么 view.post 可以获得宽高

view.post 方法能够获取视图宽高主要基于 Android 的消息处理机制以及视图的绘制和布局流程特性。

当调用 view.post 方法并传入一个 Runnable 对象时,这个 Runnable 会被封装成一个 Message(消息),然后被添加到与当前视图所在的线程关联的 Looper 的 MessageQueue(消息队列)中。通常在 Android 中,视图都是在主线程中进行绘制、布局等操作,所以对应的就是主线程的 Looper。

而主线程的 Looper 会不断循环从消息队列中取出消息进行处理,在处理这些消息的过程中,视图的测量、布局、绘制等流程会逐步推进并最终完成,使得视图的宽高得以确定下来。当通过 view.post 添加的那个 Runnable 对应的消息被取出来处理时,此时视图的布局已经完成或者接近完成,就能够在这个 Runnable 中准确地获取到视图的宽高了。

例如,在 Activity 的 onCreate 方法里,视图初始创建时直接获取宽高可能不准确,但如果使用 view.post 方法传入一个获取宽高并进行后续操作的 Runnable,等这个 Runnable 执行时,视图已经经历了完整的布局流程,就可以获取到正确的宽高数值了,代码示例如下:

public class MainActivity extends AppCompatActivity {
    private View targetView;
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
 
        targetView = findViewById(R.id.target_view);
 
        targetView.post(new Runnable() {
            @Override
            public void run() {
                int width = targetView.getWidth();
                int height = targetView.getHeight();
                // 在这里可以使用获取到的宽高进行后续操作
            }
        });
    }
}

view.post 的源码分析

在 Android 的 View 类中,post 方法的源码实现大致如下:

public boolean post(Runnable action) {
    final AttachInfo attachInfo = mAttachInfo;
    if (attachInfo!= null) {
        return attachInfo.mHandler.post(action);
    }
 
    // Postpone the runnable until we know on which thread it needs to run.
    getRunQueue().post(action);
    return true;
}

从源码可以看到,首先会获取视图的 AttachInfo,这个 AttachInfo 中包含了很多和视图关联的重要信息,其中就有一个 Handler 对象。如果 AttachInfo 不为空,也就是视图已经处于被关联到窗口等正常状态下,就直接通过这个 Handler 来发送传入的 Runnable,也就是将其封装成消息添加到消息队列中,等待被处理,这个 Handler 对应的就是主线程的 Looper 所在的处理流程。

而如果 AttachInfo 为空,说明视图可能还处于一些未完全初始化或者没有关联到合适环境的状态,此时会先把这个 Runnable 添加到视图的一个内部的 RunQueue 中暂存起来,等到视图后续的初始化等流程使得 AttachInfo 被正确赋值后,再将这些暂存的 Runnable 取出,通过 Handler 添加到消息队列中进行处理。

这样通过利用 Handler 和消息队列的机制,巧妙地实现了在合适的时机(即视图布局等流程完成后)执行传入的 Runnable,进而达到可以获取准确视图宽高的效果。

attachToWindow 什么时候调用?

attachToWindow 的调用时机

在 Android 中,View 的 attachToWindow 方法主要是在视图被添加到窗口(Window)的过程中被调用,它标志着视图开始与窗口进行关联并准备参与到窗口的显示等一系列流程中。

具体来说,对于 Activity 中的视图,当 Activity 的生命周期经历了一定阶段,开始进行窗口相关的创建、布局等操作时,视图会逐步被添加到窗口中,此时就会触发 attachToWindow 方法的调用。

例如,在 Activity 的 onResume 阶段之后,系统会着手进行窗口的一些完善工作,把 Activity 中的各个视图按照布局结构添加到对应的窗口层级中,当一个视图要正式成为窗口展示内容的一部分时,就会调用其 attachToWindow 方法,在这个方法内部,视图会进行一些初始化操作,比如初始化一些和窗口交互相关的资源、注册相关的监听器等,以便后续能够正确地在窗口环境下进行绘制、响应事件等。

对于自定义视图来说,如果是继承自 View 或者 ViewGroup 等类,在复写 attachToWindow 方法时,可以在其中添加一些自己需要的特定初始化逻辑,比如启动一些和窗口显示效果相关的动画、初始化与窗口交互的特定数据等,因为此时可以确定视图已经即将要在窗口中展示了,相关的操作条件都已具备。

而且,需要注意的是,与 attachToWindow 相对应的还有 detachFromWindow 方法,它会在视图从窗口中移除时被调用,比如当 Activity 被销毁或者视图所在的父容器将其移除等情况下,视图会执行 detachFromWindow 方法,在这个方法里可以进行一些资源的清理、动画的停止等收尾工作,保证视图的整个生命周期相关操作的完整性,避免出现资源泄漏或者异常行为等情况。

DataBinding 的原理了解吗?

DataBinding 的原理

DataBinding 是 Android Jetpack 中的一个组件,它的核心原理是基于数据绑定机制,旨在简化在 Android 应用中 UI 与数据之间的交互,实现数据变化自动驱动 UI 更新以及 UI 操作反向影响数据的双向绑定效果。

编译时处理

在编译阶段,DataBinding 会对项目中的布局文件进行解析和处理。它会识别布局文件中使用的特定语法(比如使用<variable>标签定义变量,<binding>表达式来关联数据和视图属性等),然后根据这些信息生成对应的 Java 或 Kotlin 代码,这些代码会创建一个与布局对应的 Binding 类。这个 Binding 类中包含了对布局中各个视图的引用以及用于数据绑定的相关方法和属性,相当于在代码层面建立起了布局与数据之间的连接桥梁。

例如,对于一个简单的包含 TextView 的布局文件,如果在其中定义了一个名为 userName 的变量用于绑定显示用户姓名,DataBinding 在编译时就会生成一个对应的 Binding 类,在这个类里有可以设置 userName 值的方法,并且会在合适的机制下将这个值传递给 TextView 的文本显示属性进行展示。

数据绑定的建立

在运行时,首先需要创建对应的 Binding 类的实例,一般是在 Activity 或者 Fragment 等组件中进行。通过调用 DataBindingUtil 的相关方法(比如DataBindingUtil.setContentView等)来获取 Binding 实例,这个过程中会将布局文件进行加载并关联上对应的 Binding 类,然后就可以通过 Binding 实例将数据对象和布局中的视图进行绑定操作了。

如果是单向绑定,比如只是将数据显示在视图上,当数据对象的属性发生变化时,DataBinding 内部会通过数据变化的监听机制(比如使用了 Observable 接口或者 LiveData 等可观察的数据类型)感知到这种变化,然后自动更新与之绑定的视图属性,实现 UI 的自动更新,无需手动去调用视图的 setText 等更新方法。

而对于双向绑定,例如在一个 EditText 中输入内容,输入的文本会自动更新到与之绑定的数据对象的对应属性中,同时如果数据对象的该属性通过其他途径(比如后台数据更新)发生变化,EditText 的显示内容也会随之改变,这是通过在 Binding 类中设置合适的双向绑定属性以及监听机制来实现的,使得 UI 和数据之间形成了相互影响的紧密关系。

与视图生命周期的配合

DataBinding 还会配合视图的生命周期进行工作,比如在视图的创建、销毁等阶段合理地进行数据绑定的初始化、解除等操作,避免出现内存泄漏或者数据不一致等问题。在 Activity 的 onDestroy 阶段,会自动清理掉相关的数据绑定资源,防止因绑定关系持续存在而占用不必要的内存空间,确保整个应用在使用 DataBinding 进行数据和 UI 交互时的性能和稳定性。

通过这样一套从编译时到运行时,再到与视图生命周期配合的机制,DataBinding 有效地简化了 Android 开发中 UI 与数据交互的复杂程度,提高了开发效率和代码的可维护性,尤其在构建响应式 UI 的场景下发挥着重要的作用。

最近更新:: 2025/10/23 21:22
Contributors: luokaiwen