快手 (Android) (1.2面)

泛型string的list是否能add一个数,编译是否通过?运行时是否会报错,如果想在运行时拿到泛型应该怎么做?

  1. 直接对 List<String> 调用 add(123),能不能通过编译?

    • 不能。Java 的泛型在编译期会做“类型检查”,List<String> 里只能放 String,你要放一个 Integer,编译器就报错——“不兼容的类型”。
  2. 那要是不小心绕过编译检查,在运行时会不会报错?

    • 你可以用原生类型(raw type)把编译检查给关掉:
      List raw = stringList;  
      raw.add(123);  // 编译有警告,但能过
      • 这时 123 会真的被加进底层的 ArrayList,JVM 不会在 add 那一刻抛异常。
      • 但后面你再做 String s = stringList.get(…);,底层会自动插入一次强制类型转换,相当于 (String)123,就会 ClassCastException
    • 为什么编译阻止了,但运行还会留下一次“偷加”机会?

      • 因为 Java 泛型是“类型擦除”(type erasure)机制:编译时做检查+插入必要的转换,编译后字节码里只剩下原始类型 List/ArrayList
      • 也就意味着,运行时并没有专门对泛型参数 T 的信息去做约束——插入元素时没检查,取出来才变成一次转换。
    • 如果我想在运行时真正“拿到”或者“记住”这个泛型类型,该怎么做?

      • 用反射的 ParameterizedType
        • 最简单的思路是在声明时用匿名子类去捕获类型信息:
          Type t = new TypeReference<List<String>>(){}.getType();
          • 这种模式在 Jackson、Gson、Guava 的 TypeToken 里都能看到。
          • 底层原理是在匿名类的 Class 里,JVM 会保留它继承父类时的 “实参类型” 信息,通过反射拿出来就是 ParameterizedType,能看到 String
        • 显式把类型 Class 对象传进来
          • 如果你写自己可复用的泛型类,可以在构造时加一个参数 Class<T> clazz,或者 TypeReference<T> typeRef,把泛型信息从外面传进来,自己保存,再用来做反射/序列化等。
        • 库里常见的套路
          • Guava 的 TypeToken<T>,Gson 的 new TypeToken<List<String>>(){}.getType(),Jackson 的 new TypeReference<List<String>>() {}
          • 这些都是“在匿名子类里让编译器留存泛型实参”,运行时通过反射 API (getGenericSuperclass()) 拿到 String

假设一个static变量,更新它的值,另一个进程能否获取它的最新值?

有一个 Class 里定义了一个 static 变量 A,进程 P1 更新了它的值,进程 P2 想去读这个变量,能不能拿到最新的值?答案是:不能直接拿到。原因和关键点大概分两层来聊。

  1. 进程内 vs 进程间的内存隔离

    • Java/Android 里的 static,本质就是类加载器在当前进程里给这块内存留了一块全局变量区,属于“当前JVM(在 Android 上就是当前 Linux 进程)”的。
    • 不同进程各自启动自己的虚拟机,分配独立的堆和方法区,互相看不到对方内存里的任何数据。所以 P1 里那个 static A 改了之后,P2 不会感知,P2 还是用自己进程内初始化时的默认值。
  2. 如果只是同一进程不同线程,是另一回事

    • 如果是 P1 里多个线程访问同一个 static 变量,更新后要想及时可见,得考虑 Java 内存模型:要么把它声明成 volatile,要么在 synchronized/Lock 等同步块里操作,保证线程之间有“内存栅栏”,更新后其它线程才能看到最新值。
    • 但无论 volatile 还是 synchronized,都 解决不了跨进程 的可见性问题,因为进程间根本没有共享内存区域。
  3. 真要在多个进程间共享状态,通常有这些方案

    • Binder IPC:写一个 Service(AIDL 或者 Messenger) ,P1 更新时通过 Binder 通知或暴露 get/set 接口,P2 通过 Binder 调用拿最新值。
    • ContentProvider:把状态写到 ContentProvider 或者 SQLite、文件里,P2 通过 ContentResolver 读。
    • EventBus + ProcessEventHook(底层其实也是 Binder)。
    • 甚至更底层的 Socket、SocketServer,或者你们自己搭一个跨进程的小通信框架。
  4. 面试官真正想考的是

    • 进程独立性:每个进程都有各自的 ClassLoader、方法区、堆,static 不跨进程。
    • 内存可见性:同一进程要考虑 volatile/synchronized,跨进程则必须用 IPC。
    • 解决思路:光靠 static 天然的“全局”是不够的,分布式就要用进程间通信。

所以我会总结说:

  • static 在同一进程内可以当成“全局单例”用,线程间可见性要靠 volatile/sync;
  • 不同进程是完全隔离的,static 值更新后,别的进程拿不到,你得用 Binder、Provider 之类的 IPC 机制来同步数据。

volatile关键字的作用?在双重检验锁中,不使用它会有什么问题?

  1. volatile 的两个核心作用

    • 可见性:普通变量在多线程下,一个线程改了值,别的线程不一定能马上看到——它们可能各自缓存了老值。而 volatile 修饰后,写操作会立刻刷新到主内存,读操作也总是去主内存拿。这保证了不同线程之间对这个变量的修改是“立刻可见”的。
    • 防止指令重排序:虚拟机会为了性能,对指令做重排序优化。但对 volatile 变量,JVM 会在它前后插入内存屏障(memory barrier),禁止把对这个变量的读/写和屏障前后的其他操作乱序合并。这样能在多线程场景下避免一些“半完成”状态被别的线程看到。
  2. 双重检验锁(DCL)里为什么要用 volatile
    DCL 的套路是:

    1. 先检查实例是不是空;
    2. 如果是空,就进 synchronized 块再检查一次,然后 new 实例;
    3. 出块后直接返回实例。
      问题在于,new Foo() 在字节码层其实分三步:
      A. 给 Foo 分配内存
      B. 调用构造方法初始化
      C. 把引用赋值给那个变量
      如果没有 volatile,JVM 可以把 B/C 步骤重排序成 “分配内存→给变量赋引用→再执行构造”,那么:
    • 线程1 走到 synchronized 里开始 new,它可能先把引用写进变量,还没初始化完;
    • 这时线程2 跳过 synchronized,拿到的引用不为 null,就直接用,结果访问到还没初始化完的对象,就会出各种奇怪的 NPE 或数据不一致。

    volatile 一来能保证这三步对外“看得见”的执行顺序:写引用前一定把初始化操作都做完,读引用后也能拿到完整构造后的对象;二来保证写入引用立刻刷到主内存,别的线程马上能看到。

  3. 不用 volatile 会碰到的“怪现象”

    • 拿到半初始化对象:最常见的就是项目里报莫名其妙的 NPE,明明单例 init 完了,怎么抛空指针?原因往往是线程竞态+重排序导致的“鬼对象”。
    • 可见性问题:即便不涉及重排序,A 线程 new 完了,B 线程也可能一直读到 null 或老值,导致整个 DCL 失效,还是会进入 synchronized 不断尝试,浪费性能。
  4. 总结我的理解

    • 如果你只是想保证多线程能看到最新值,又不需要锁性能开销,就用 volatile;它比 synchronized 轻量。
    • 在 DCL 这种“先读后锁,再读后写”的方案里,volatile 是必不可少的,既保证内存可见性,又挡住编译器/JVM 的重排序优化。
    • 实战中要么干脆用静态内部类/枚举单例更安全(JVM 天生保证初始化安全),要用 DCL 就不能忘 volatile,否则看似“对了”却会在高并发下露出致命 bug。

双重检验锁(Double-Checked Locking)主要是一个用来解决在多线程环境下安全地延迟初始化单例(或其他资源)的设计模式。简单说来,就是在获取实例前先判断是否已经创建过,如果没有再进行同步,然后在同步块内部再判断一次,确保只创建一个实例。

synchronized关键字,修饰类和修饰方法有什么区别

  1. “修饰方法”——那其实又分两种情况
    a. 实例方法加 synchronized

    • 相当于在方法体最外层包了一层 synchronized(this),锁住的是当前对象的 monitor。
    • 好处是:同一个实例的多线程调用会排队,保证对实例字段读写的安全;
    • 不会影响同一类的其他实例:比如你有两个 Activity A 和 B,各自 new 出来的两个对象,线程抢锁时互不干扰。
      b. 静态方法加 synchronized
    • 相当于在方法体最外层包了一层 synchronized(YourClass.class),锁住的是这个类对象的 monitor。
    • 它能保证所有线程对这个类的静态字段或静态方法调用同一把锁,同一时刻只能一个线程进来;
    • 即便是不同实例,只要都调用了这个静态 synchronized 方法,还是会串行。
  2. “修饰类”——通常指在代码里写 synchronized(SomeClass.class){ … }

    • 锁对象和静态同步方法是一样的:都是锁 SomeClass.class 这个“类唯一实例”;
    • 但是更灵活:
      • 你可以在一段更精细的逻辑前后加锁,而不用把整个方法都锁住;
      • 例如只锁住关键的那几行,能把锁竞争的粒度缩小,减少阻塞,提升并发度。
  3. 具体区别归纳

    • 锁定对象不同:实例方法 → 锁 this;静态方法 或 synchronized(类) → 锁 Class 对象;
    • 影响范围不同
      • 实例锁只影响那个对象的 synchronized 方法/块;
      • 类锁影响该类所有 static synchronized 方法,以及任何 synchronized(类) 块;
    • 粒度和灵活性
      • 方法级 synchronized(尤其是静态方法),锁粒度粗,写起来简单,但范围大,容易造成不必要的排队;
      • 块级 synchronized(类)/(this) 可以自行控制加锁时机和范围,更能兼顾安全和性能;
  4. 什么时候用哪种

    • 对实例成员变量做并发保护,且整个方法逻辑都依赖该锁,就直接写成实例方法 synchronized
    • 对全局、跨实例的静态资源(比如单例里的静态 Map、全局计数器)要加锁,就用 static synchronized 或同步某个 Class 对象;
    • 只有一小段共享逻辑才需要互斥,其他部分无关紧要,就用块级 synchronized,避免整个方法都被锁死。

activity如何与fragment通信?

  1. 通过接口回调
    这种方式是在Fragment中定义一个接口,然后让Activity去实现接口。当Fragment需要通知Activity一些操作或数据时,就直接调用接口方法。这样做有个好处,就是解耦了Fragment与Activity的具体实现关系,也比较容易维护。
     

  2. 通过共享ViewModel
    尤其是在采用MVVM架构和使用Jetpack组件的现阶段,共享ViewModel是一种非常推荐的做法。Activity和它里面的Fragments可以在同一个作用域内获得同一个ViewModel实例,这样一来,数据变化通过LiveData或者StateFlow通知到所有观察者。
    这种方式可以让数据传播变得自动化,并且解决了生命周期感知的问题。比如Fragment里更新数据,Activity和其他Fragment都会自动收到更新,非常适合对数据状态管理要求高的场景。

  3. 直接调用Fragment的方法
    虽然不太推荐,但在某些简单场景下,也可以在Activity中通过FragmentManager找到Fragment的实例,然后直接调用它的公有方法。当然这种方式耦合性较高,不利于后期维护或重构,所以一般只作最后手段。

  4. 使用事件总线(EventBus)
    有些项目会采用诸如EventBus、RxJava等中间件来实现解耦的事件通信。这种方式不是Activity和Fragment直接通信,而是通过消息中间件传递事件。当一个组件发布事件,其他相应组件可以订阅到消息。不过这种方式的缺陷是可能会导致事件的分发过于隐晦,调试起来比较麻烦,需要小心管理事件的注册和注销。

  5. 一次性传参:通过 Bundle/arguments

    • 场景:Activity 创建 Fragment 的时候,给它一些“启动参数”,比如一个 ID、一个 URL。
    • 做法:new Fragment 的时候,创建一个 Bundle,put 好所有必需字段,然后 setArguments(bundle),Fragment 里在 onCreate 或 onCreateView 阶段通过 getArguments 拿到。
    • 好处:简单、生命周期安全,Android 官方推荐的“静态工厂方法”模式。

在VIew中,requestlayout和invalidate方法有什么区别?

首先,invalidate()主要用于刷新View的绘制,当我们的View数据改变或者状态需要改变时,调用invalidate()会告诉系统,“嘿,需要重新绘制这个View”。其实invalidate()最终会在合适的时机调用onDraw()方法来重新绘制当前View,所以说它是用来更新View的视觉呈现。比如说背景颜色、图片或者文本发生了改变,我就会调用invalidate()。

而requestLayout()则是用来重新布局的。当View的尺寸、位置或者布局参数有变化的时候,我们需要让系统重新计算整个View的布局,这时候就会调用requestLayout()。调用这个方法会让整个布局过程重新走一次,也就是说会从measure到layout,最后调用onDraw()。这种方式就会比单纯调用invalidate()多一个measure和layout的过程,所以相对耗时会高一些。

总的来说,invalidate()用于对已有布局的局部刷新,而requestLayout()则会触发整个View重新测量和布局,再加上绘制。实际开发中,我会根据情况来选择。比如,如果只是想更新一些样式或者颜色,就用invalidate();如果涉及到控件的尺寸、边距等变化,就要用requestLayout()来确保布局合适。

场景题:实现一个根据优先级对子view进行测量的自定义ViewGroup

首先,我会扩展原有的 LayoutParams,给它增加一个优先级属性,这样在 XML 或代码中,我们就能为每个子 View 指定一个优先级。优先级高的子 View 就能在测量和布局时优先获取资源或位置。

  • LayoutParams 是 Android 里一个非常重要的类,它用来描述 View 在父布局(ViewGroup)中的一些布局规则和属性。

接着,在 onMeasure 方法里,我会先遍历所有子 View,然后根据它们的优先级进行排序,让优先级最高的排在前面。这样,在测量的时候可以先处理高优先级的 View,看它们需要的宽度和高度是多少,再根据这些数据分配剩余空间给后面的 View。举个场景,比如说有些控件比较重要,我可以保证它们得到足够的空间,而如果低优先级的控件空间不足,也可以做适当的调整或者缩水处理。

不仅如此,在 onMeasure 中我也会注意对子 View 的 Margin、Padding 等进行处理,因为这关系到布局的真实宽度和高度。可能的话,还会考虑加上一些动态的空间分配策略,比如说如果高优先级的 View 占用的空间超出了一定比例,可以再做个限制,然后把剩下的空间平均分配或按照不同规则分配给低优先级的。

在 onLayout 方法中,核心思想其实还是依赖于在 onMeasure 中得出的顺序。这里我会按照优先级排序之后确定的顺序来逐个定位每个子 View。比如,假设我们是按照垂直排列的话,我会计算出每个子 View 的 top、left、bottom、right,然后依次往下布局。这样一来,高优先级的子 View就总是出现在上面或者更显眼的位置。

整个过程中,我会尤其注意性能和用户体验的问题。因为每次重新测量和布局都会有一定的开销,所以如果子 View 的变化不大,我也会考虑缓存测量结果或者避免不必要的重新排序。其实主要思路就是:通过扩展 LayoutParams 把优先级带进来,然后在 onMeasure 和 onLayout 方法中都利用这个优先级顺序来计算各个控件的位置和大小。

重载和重写的区别和联系

首先,重载(Overloading)是指在同一个类里存在多个同名的方法,只不过它们的参数列表不一样,可能参数类型、个数、顺序不一样。重载体现在编译期,编译器在编译时就会根据传入参数确定调用哪个具体的方法。这主要是为了提升代码可读性和提供灵活性,比如在传不同类型和数量参数的时候,能够让方法名保持一致,调用起来更直观。

而重写(Overriding)则是继承体系中比较典型的一种表现,也就是子类对父类中已经定义的方法进行重新实现。重写讲的是方法的实现细节,是在运行时根据实际对象的类型来决定调用哪个版本的方法,是典型的运行时多态。这里要求方法的签名跟父类的不变,同时还要遵守访问修饰符和其它限定条件,比如不能缩小父类方法的访问范围。

两者虽然都是多态,但侧重点不同:重载主要解决同一功能在参数不同场景下的调用问题,体现的是编译时多态;而重写是为了在继承体系中重新定义行为,体现的是运行时多态。这两种机制虽然目的都是为了提高代码灵活性和复用性,但在实际应用中,重写往往用来适应不同子类间的定制需求,而重载则是在方法命名上一致、外部调用上简单直观的设计理念。

讲到联系,其实两者都属于多态性的一部分,都允许我们在一定程度上根据参数或对象类型做出不同的行为选择。一方面,重载不涉及继承关系,但是它也展示了方法名字的多样化;另一方面,重写是在继承关系中的“同名方法”中作出的不同实现,利用了动态绑定使得实际调用的行为可以根据对象的具体类型而变化。

hashmap的实现,有哪些线程安全的集合类

HashMap 的实现其实背后是一个数组加上链表或者红黑树(当链表特别长的时候)的组合结构,目的就是在快速查找的同时减少因碰撞导致性能下降。不过 HashMap 本身是不保证线程安全的,这点在多线程开发中就要多加注意。

说到线程安全的集合类,我通常会提到几个:

  1. Hashtable
    这是一种老的实现,它其实是一个完全同步化的类。每个方法都做了同步处理,所以在多线程环境下它是线程安全的。但是正因为是整体同步,性能上会有一些瓶颈,所以现在很多项目都不会用它。

  2. Collections.synchronizedMap
    这是通过对一个普通的 HashMap 包装一层同步锁来实现线程安全的方式。其实这是一个装饰者模式,任何对 map 的操作都是在 synchronized 块里执行的,保证线程安全。但问题也是一样,所有操作都是串行化的,性能相对来说不够高效。

  3. ConcurrentHashMap
    这是目前最常用的线程安全 Map。它相比 Hashtable 更加高效,因为它采取了分段锁或者细粒度的同步(在 Java 8 之后甚至采用了一些 CAS 操作来优化结构扩容等),这样多个线程在不同的段中操作时是互不影响的。它设计的目的就是满足高并发场景下良好的吞吐量,同时还保持了较高的线程安全性。

这些都是我平时会考虑的主要线程安全集合类。从实际应用角度来说,我个人更倾向于使用 ConcurrentHashMap,因为它在性能和安全性之间取得了一个比较好的平衡。Hashtable 和 Collections.synchronizedMap 虽然也能保证线程安全,但毕竟锁粒度比较粗,容易导致性能瓶颈,而且现在新项目很少用它们,主要是出于历史遗留或者兼容性原因。

synchroized和volatile的区别

首先,synchronized主要用于解决线程间的互斥问题,也就是确保同一时刻只有一个线程能进入某个代码块或者方法。当我使用synchronized时,实际上是在声明一个临界区,只要一个线程进入了这个临界区,其他线程就必须等待它退出后才能进入。这不仅保证了数据操作的原子性,还会自动做内存的同步处理,让进入和退出临界区的线程都能看到其他线程对共享数据的修改。

再说volatile,它主要解决的是可见性问题。比如说,当一个线程修改了一个volatile修饰的变量,其他线程在读取这个变量时就能立刻看到最新值。volatile通过内存屏障来确保在写操作之后,其他线程能够立即获取到更新后的值,同时也防止指令重排序。但要注意,volatile并不具备互斥性。如果我只是单纯地对一个volatile变量赋值或者读取,它能保证变化的可见性,但如果多步操作需要作为一个整体执行,就不能依靠volatile来保证原子性。

因此,我通常会这么理解:

  • 当数据的操作只涉及简单的写入或读取,且不需要组合操作时,可以使用volatile确保内存一致性。
  • 但如果是有多个步骤的组合操作,或者需要保证某个代码块只被一个线程访问,就必须用synchronized来实现互斥。

同时,synchronized的开销比volatile大一些,因为要涉及到加锁和解锁的过程,而且在高并发下可能会引起线程等待。而volatile由于没有锁竞争,所以性能上更轻量,但它只能用在某些特定场景下满足需求。

讲讲atomic

atomic主要指的是原子性操作,通常在Java和Kotlin中我们会用到java.util.concurrent.atomic包里的各种变量,比如AtomicInteger、AtomicBoolean等。简单来说,原子性操作就是保证了在多线程环境下,一个操作要么完全执行成功,要么完全没有执行,不能被其他线程打断,也不会出现中间状态。这对于避免并发问题、数据竞争很有帮助,不需要像synchronized那样使用锁机制,从而减少开销。

具体来说,atomic变量的工作原理通常是基于CAS(Compare And Swap,比较并交换)这个算法。CAS会去检查当前内存中的值是否和预期一致,如果一致,就能够把新的值写进去,这个过程是原子的,不会被其他线程干扰。如果不一致的话,就会重试或者根据业务逻辑采取其他措施。这样就避免了传统锁带来的阻塞问题,提高了性能和并发量。

我平时使用atomic变量的时候,比如在计数器、状态标识这些简单的场景下特别合适,因为它们不需要复杂的同步,而只需要保证单一的读写一致性。与volatile相比,volatile只保证可见性,但并不能保证复合操作的原子性,而atomic操作不仅有可见性,还保证了在更新时不会被其他线程干扰。

总的来说,从我的角度看,atomic类提供了一种轻量级的线程安全方案,让我们可以在高并发场景下做到不加锁就能保证数据正确性,这是非常高效和实用的。在实际项目中,特别是对于高并发性能要求比较高的部分,我会考虑用atomic变量来优化性能。这样既减少了锁竞争带来的性能损失,也能确保数据更新的原子性,让代码看起来也更简洁。

activity之间如何实现通信?传递的数据是否大小限制

我平时在开发中,Activity之间通信主要依靠Intent来传递数据。具体来说,就是在启动另一个Activity的时候,把需要传递的数据放到Intent的Extra当中,然后在目标Activity的onCreate或者onNewIntent中获取这个数据。这种方式比较直观,使用起来也比较方便,毕竟Android系统最初就设计成通过Intent来实现组件间的通信。

关于数据的大小限制,这里有一点要特别注意:Intent内部实际上会使用Binder来传递数据,而Binder有个事务大小限制,大概是1MB左右(实际值可能略有不同,但不宜传输太大的数据)。如果你传递的数据太大,就可能抛出TransactionTooLargeException,导致程序崩溃。

因此在实际应用中,如果需要传递较大的数据,我一般不会直接放Extras里,而是采用其他方案,比如把数据存储在文件、数据库或者全局共享的对象中,然后通过Intent传递一个标识符或URL来让目标Activity自行获取数据。另外,使用SharedPreferences或甚至ViewModel结合LiveData其实也能在一定程度上处理这种需求,尽量避免直接传递大量数据。

总的来说,Activity之间通信通常有Intent传递、startActivityForResult以及通过广播这种方式,选择哪种方式主要看数据的类型和应用场景。如果只是少量的字符串、数字或者数组,Extras绝对够用;但如果涉及到bitmap、列表或其他大对象,就需要谨慎考虑数据量问题,以及考虑如何做到数据的持久化或者延迟加载,以免遇到Binder传输的瓶颈。这样可以保证我们的程序既高效又稳定。

activity与service如何通信

首先,我会考虑的是通过绑定(bindService)的方式来实现。通过这种方式,Activity可以绑定到Service上,拿到Service的实例之后,就可以直接调用Service中的公共方法来传递数据或者请求服务。这样做的好处是通信非常直接,而且可以实现双向通信,互相调用都很方便。不过,这种方式也要求Service必须实现绑定逻辑,并且随着Activity的绑定和解绑,生命周期也会受到影响。

其次,如果只是单纯地启动Service去做一些后台操作,Activity可以通过startService来启动Service,然后通过Intent传递初始的数据。之后如果想要让Service主动给Activity反馈数据,可以通过发送广播或者在Service中借助Messenger来实现跨进程或者跨线程的消息传递。这种方式在解耦上比较灵活,尤其是当Service需要在后台长时间运行,而且多个组件可能都要收到同样的信息时,用广播比较适合。

另外,还有一种情况是使用AIDL(Android Interface Definition Language),主要是当Service和Activity运行在不同进程时,这时就需要用AIDL来定义接口,通过IPC来保证数据传输。尽管这种方式比较复杂,但是在需要进程间通信的场景下它的优势就显现出来了。

还有,实际开发中我们也会用到一些第三方库或者EventBus这种消息订阅模式来实现类似的通信效果,使得代码更加松耦合和可维护。

总结一下,Activity与Service通信主要有两种思路:

  1. 如果需要紧密交互,比如需要在Activity中主动调用Service的方法,可以选择绑定方式,这样通信直接且双向。
  2. 如果只是启动服务并在后台处理任务,然后偶尔需要回传数据,可以用Intent传递初始参数,然后通过广播、Messenger或者AIDL来完成数据反馈,特别是在跨进程或解耦需求比较高时。

如何减少布局嵌套

减少布局嵌套主要是为了优化性能和提高渲染效率。比如说,有时候我们在设计界面时习惯于一层一层地嵌套各种布局组件,结果就会导致布局的深度变得非常深,影响解析和绘制的速度。我会考虑使用一些平铺布局的方式来替代多个嵌套,比如说利用 ConstraintLayout 的约束特性,它可以让我们在一个层级中实现复杂布局,通过限制条件来定位各个控件,这样既能达到设计效果,也能避免多层嵌套。

另外,如果只是对部分控件进行简单排列,我会使用线性布局或者甚至使用 FrameLayout 来简化嵌套层级。还有的情况下,我会将一些静态的装饰部分提前绘制成图片或者自定义控件,这样可以减少层级。最终目标就是让布局层级尽量扁平,不仅能提高渲染效率,也能让代码结构更清晰、更易于维护。FrameLayout 是 Android 中最简单的布局之一,它的核心特点就是用于“堆叠”子视图。它的作用是把所有子 View 按照“层叠”的方式放置,也就是说,后添加的子 View 会覆盖在前一个子 View 的上面。

总之,我会在项目中根据具体需求和性能表现不断优化布局,尽量使用现代布局方案(比如 ConstraintLayout、CoordinatorLayout 等)来替代复杂嵌套,从而达到既美观又高效的效果。

线性布局的优点

首先,它的实现非常简单直观,主要就是把子控件沿着某个方向(水平或者垂直)一个接一个排列。这种排列方式让整个布局的结构很清晰,也很容易理解和维护,对我们的开发效率有很大帮助。

其次,线性布局在测量和布局上也比较容易控制,因为只需要分别计算子控件沿着主轴的尺寸和位置,然后再处理一下次轴的对齐即可。这种一维的计算在性能上通常不会涉及太多复杂的递归调用,所以在简单场景下它的渲染速度和效率都会很好。

再者,当需求比较简单,比如说只有一组并排或者一列控件时,线性布局能够迅速满足设计需求,不需要引入复杂的约束或者额外的层级。如果我们能用更简单的方式实现需求,自然就不会为了兼容复杂场景而增加不必要的计算开销。

另外,线性布局的学习曲线也比较平缓。对于新手来说,很容易就能上手并理解如何通过设置权重或间距来分配空间,这在团队协作中也能提升整体开发速度和协同性。

当然,有时我们也需要注意线性布局可能存在嵌套层级较深的问题,所以在设计时会权衡简单布局和优化层级的情况。但总的来说,面对简单的一维排列需求,线性布局是一个非常直观、高效、易懂的选择,也能很好地适应大多数常见场景,所以在面试中常常能体现出我们对Android布局系统的理解和实践经验。

自定义VIew的实现方式

  1. 继承现有的 View——比如说,如果只是对某个控件的绘制或者行为进行扩展,我会直接继承 Android 中的某个现有 View,比如 TextView、ImageView 或者直接继承 View。这种方式适用于那些想在原有控件上做一些简单调整的情况,比如增加额外的绘制效果、响应自定义手势事件或者做一些特定的动画效果。

  2. 组合方式——有时候直接继承 View 不是最优选择,而是通过组合几个子 View 来构成一个复合视图。这样做的好处是可以利用已经成熟的组件来实现复杂效果,也有利于代码复用与维护。如果没有特别需要完全自定义渲染,我就会考虑用组合方式,例如封装多个控件,内部用简单的布局来控制各个子 View 的位置和大小,然后暴露一些接口去定制化这一块。

  3. 重写绘制流程——如果需求非常特殊,需要完全掌控绘制过程,这种情况下就会直接继承 View 并重写 onMeasure、onDraw 等方法。这样可以精准地控制如何测量和绘制,通常适用于性能要求比较高的场景或者需要有定制图形展示的时候。用这种方案的时候,我会先确保理解好绘制顺序、MeasureSpec 的处理,并且考虑好相应的重绘逻辑,比如 invalidate 调用时如何同步更新视图。

在自定义 View 的过程中,我特别注意以下几点:

  • 尽量减少不必要的资源开销,比如避免重复创建对象,充分利用硬件加速等。
  • 对于 onMeasure 和 onLayout 的实现要小心,测量过程中尽量做到精准计算大小,避免因为嵌套过深或者复杂的计算导致性能损耗。
  • 如果涉及到重绘,onDraw 中画布的状态管理和抗锯齿处理也很重要,尽可能在不影响性能的前提下达到理想的效果。
  • 另外中间的事件分发,比如 touch 事件的处理,通常要根据需求来决定是否捕获事件或者让子 View 参与,这都需要根据实际场景进行测试验证。

VIew的渲染流程

首先是测量阶段,也就是我们常说的onMeasure。这个阶段主要是解决“我到底要多大”的问题。系统会递归地从根View开始调用每个View的onMeasure方法,去计算出合适的宽度和高度。这里需要注意的是,这个过程会受到父View传递下来的MeasureSpec的限制,不管是精确的尺寸、最大值还是无约束的模式,都会影响到最终的测量结果。在这里,我一般会特别注意如何在自定义View中正确处理MeasureSpec,以及如何响应wrap_content这种情况。

接下来就是布局阶段,也就是onLayout。经过测量之后,每个View都有了自己的尺寸,接下来就需要确定每个View在父容器中的具体位置。这个阶段,系统同样会递归地调用布局方法,把每个View放在合适的位置。对于ViewGroup来说,它不仅要处理子View的位置,还得考虑好内边距和子View之间的间隔关系。一旦布局完成,每个View的四个边界就清楚地确定下来了。

最后就是绘制阶段,这个阶段通常是在onDraw方法中进行,也就是画出实际的内容。对于普通的View,它可能是绘制背景、内容和前景等。而对于ViewGroup来说,还需要调用dispatchDraw方法去绘制子View,这就涉及到了绘制顺序的问题。一般来说,背景先绘制,然后是View自身的内容,再把子View绘制进去。另外,在绘制过程中,画布(Canvas)的变换、裁切以及图层混合模式都会对最终的效果产生影响,所以有时候我们需要手动处理这些细节。

还有一点需要注意的是,整个渲染流程都是在UI线程中顺序执行的,所以一旦某个阶段处理得比较耗时,就会造成界面的卡顿。因此,在项目中我经常会优化onMeasure、onLayout和onDraw的方法,尽量避免过于复杂的计算,同时也会控制View层次的深度,避免不必要的嵌套,从而提升整个页面的渲染效率。

recycledVIew的优化方式

首先是使用ViewHolder模式,这是RecyclerView设计的核心,就算现在它已经内置了这个模式,但我还是会注意确保ViewHolder内部不会做过多耗时操作,比如避免在onBind的时候频繁地进行findViewById或复杂计算,最好在ViewHolder构造时就绑定好各个控件。

其次,我会尽量优化item布局。简单的布局层级就意味着更快的测量和渲染速度。比如在设计item时,我会考虑使用ConstraintLayout或者其他扁平化布局来代替深层的嵌套布局,确保单个item的UI绘制是高效的。实际项目中,一个复杂的item可能会严重影响滚动的流畅性,所以一定要精简布局层次。

另外,我还会重点关注数据更新的优化。比如,当数据变动时,我会使用DiffUtil来计算数据差异,而不是直接调用notifyDataSetChanged,这样可以让局部刷新更加智能、平滑。这样就只更新真正变化的部分,减少重复绘制,提高性能。

再有,我也会合理地管理RecyclerView的缓存策略。RecyclerView在屏幕外会缓存一定数量的item,而默认的缓存区大小可以根据具体场景进行调整。如果列表滚动非常快,我可能会适当地增加预加载的item数量,防止频繁创建和销毁item。同时,对于一些状态不常变化的item,我也会借助setIsRecyclable方法,让它们在必要时不被回收,从而减少不必要的重绘或者数据重设。

还有一个点是,尽量将耗时的操作移到子线程上,比如图片加载或者数据处理,因为在绑定数据时如果阻塞了UI线程,就会影响RecyclerView的平滑滚动。通常我会借助一些成熟的图片加载库来异步加载网络图片,确保数据加载和UI渲染是解耦的。

bitmap如何优化然后bitmap池是什么

首先,我会尽量控制图片的尺寸和分辨率。不必要的高分辨率图片会增加内存使用,所以在加载图片时,经常会用一些工具或者配置,比如使用inSampleSize,在加载之前先对图片进行采样压缩,这样可以在保证图片质量的基础上减少内存开销。

再者,就是图片的内存复用。在Android 3.0以后,我们就可以使用inBitmap这个特性,让多个bitmap复用同一个内存区域。由于创建bitmap和GC回收bitmap都是非常耗时的操作,复用内存可以大大降低内存抖动和内存垃圾的产生。这个时候bitmap池就发挥了作用。

说到bitmap池,简单来说,它就是一个缓存池,用于保存已经加载过的bitmap内存。当我们需要一个新的bitmap时,可以先看看池子里有没有合适的复用对象,而不是重新申请一块新的内存。这样不光减少了频繁创建和销毁bitmap带来的内存开销,还能大幅提升应用的运行效率。当然,管理bitmap池的时候,也要注意回收和防止内存泄漏的问题,因为池子里的bitmap如果管理不当,反而可能会占用太多内存。

除此之外,我还会注意图片加载的一些配置,比如使用图片加载库(Glide、Fresco等)来辅助管理内存和缓存。它们内部其实也会使用bitmap池技术,在后台自动管理bitmap内存,帮助我们尽可能地做到复用而不是重复分配。

总的来说,bitmap优化的核心在于减少不必要的内存占用、合理采样压缩、推行内存复用。而bitmap池作为内存复用的重要手段之一,可以显著降低内存分配和回收的负担,确保图片加载更高效、更流畅。

场景题:生产者消费者模式,Koltin实现

下面给个示例代码,利用Kotlin协程和Channel来实现一个简单的生产者消费者模式。这个示例中,生产者不断产生整数,并通过Channel发送出去,消费者从Channel中接收数据进行消费,生产和消费的速度不一致时Channel会起到缓冲作用。

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel

/*
runBlocking 是一个非常常用的函数,主要用于在普通的非协程环境中启动一个协程并阻塞当前线程,直到协程内部的代码执行完成。
 */
fun main() = runBlocking {
    // 创建一个容量为5的Channel作为缓冲区
    /*
    在 Kotlin 的协程里,Channel 是一个非常重要的概念,它可以被理解为一种用于在协程之间传递数据的工具
    简单来说,它类似于生产者-消费者模式,允许一个协程发送数据(生产者),另一个协程接收数据(消费者)
    它的作用就是在协程之间建立一种通信机制。

    capacity = 5:表示这个 Channel 的缓冲区容量是 5。也就是说,最多可以存储 5 个元素。
    如果缓冲区满了,发送方会被挂起,直到消费者取走数据腾出空间。
    如果缓冲区为空,接收方会被挂起,直到有数据可用为止。


     */
    val channel = Channel<Int>(capacity = 5)

    // 生产者协程:持续生产数据,每个数据延迟100毫秒
    val producer = launch {
        var counter = 0
        while (true) {
            println("生产者生产:$counter")
            channel.send(counter)  // 发送数据到Channel
            counter++
            delay(100L)
        }
    }

    // 消费者协程:持续消费Channel中的数据,每消费一项延迟300毫秒
    val consumer = launch {
        for (item in channel) {
            println("消费者消费:$item")
            delay(300L)
        }
    }

    // 让生产者、消费者运行一段时间,比如5秒钟
    delay(5000L)
    println("主协程:取消生产者和消费者")
    producer.cancelAndJoin()
    consumer.cancelAndJoin()
    println("主协程:结束")
}

解释下这个实现:

  • 利用了Kotlin协程库中的Channel作为生产者和消费者之间的缓冲区,Channel的capacity为5,表示最多可以缓存5个数据。
  • 生产者协程每100毫秒生产一个整数,并通过channel.send()方法发送到Channel中。
  • 消费者协程通过for循环遍历Channel中的数据进行消费,每消费一次延迟300毫秒,从而模拟消费速度较慢的情况。
  • 主协程等待5秒后,通过cancelAndJoin()取消生产者和消费者,保证程序可以正确退出。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值