【通用开发】设计模式

【通用开发】设计模式

本文介绍了Java项目开发中最常见的几种设计模式

高内聚&低耦合

这是最常见的一个概念,也是各个设计模式的最基本的原则。

高内聚

模块内部的各个类,要实现紧密连接,功能上高度的相互配合。按照内聚程度从低到高主要有以下几种:

  • 偶然内聚:一个模块内的各处理元素之间没有任何联系,只是偶然地被凑到一起。这种模块也称为巧合内聚,内聚程度最低。
  • 逻辑内聚:这种模块把几种相关的功能组合在一起, 每次被调用时,由传送给模块参数来确定该模块应完成哪一种功能 。
  • 时间内聚:把需要同时执行的动作组合在一起形成的模块称为时间内聚模块。
  • 过程内聚:构件或者操作的组合方式是,允许在调用前面的构件或操作之后,马上调用后面的构件或操作,即使两者之间没有数据进行传递。简单的说就是如果一个模块内的处理元素是相关的,而且必须以特定次序执行则称为过程内聚。
    • 例如某要完成登录的功能,前一个功能判断网络状态,后一个执行登录操作,显然是按照特 定次序执行的。
  • 通信内聚:指模块内所有处理元素都在同一个数据结构上操作或所有处理功能都通过公用数据而发生关联(有时称之为信息内聚)。即指模块内各个组成部分都使用相同的数据结构或产生相同的数据结构。
  • 顺序内聚:一个模块中各个处理元素和同一个功能密切相关,而且这些处理必须顺序执行,通常前一个处理元素的输出时后一个处理元素的输入。
    • 例如某要完成获取订单信息的功能,前一个功能获取用户信息,后一个执行计算均价操作,显然该模块内两部分紧密关联。
  • 顺序内聚的内聚度比较高,但缺点是不如功能内聚易于维护。
  • 功能内聚:模块内所有元素的各个组成部分全部都为完成同一个功能而存在,共同完成一个单一的功能,模块已不可再分。即模块仅包括为完成某个功能所必须的所有成分,这些成分紧密联系、缺一不可。

低耦合

耦合,即模块间的关联程度,模块间的耦合度是指模块之间的依赖关系,包括控制关系、调用关系、数据传递关系。模块间联系越多,其耦合性越强,同时表明其独立性越差。

按照耦合程度从低到高排布如下:

  • 非直接耦合:两个模块之间没有直接关系,它们之间的联系完全是通过主模块的控制和调用来实现的。耦合度最弱,模块独立性最强。
  • 数据耦合:调用模块和被调用模块之间只传递简单的数据项参数。相当于高级语言中的值传递。
  • 标记耦合:调用模块和被调用模块之间传递数据结构而不是简单数据,同时也称作特征耦合。表就和的模块间传递的不是简单变量,而是像高级语言中的数据名、记录名和文件名等数据结果,这些名字即为标记,其实传递的是地址。  
  • 控制耦合:模块之间传递的不是数据信息,而是控制信息例如标志、开关量等,一个模块控制了另一个模块的功能。
  • 外部耦合:一组模块都访问同一全局简单变量,而且不通过参数表传递该全局变量的信息,则称之为外部耦合。
  • 公共耦合:一组模块都访问同一个全局数据结构,则称之为公共耦合。公共数据环境可以是全局数据结构、共享的通信区、内存的公共覆盖区等。如果模块只是向公共数据环境输入数据,或是只从公共数据环境取出数据,这属于比较松散的公共耦合;如果模块既向公共数据环境输入数据又从公共数据环境取出数据,这属于较紧密的公共耦合。公共耦合会引起以下问题:

    无法控制各个模块对公共数据的存取,严重影响了软件模块的可靠性和适应性。 使软件的可维护性变差。若一个模块修改了公共数据,则会影响相关模块。 降低了软件的可理解性。不容易清楚知道哪些数据被哪些模块所共享,排错困难。 一般地,仅当模块间共享的数据很多且通过参数传递很不方便时,才使用公共耦合。  

  • 内容耦合:一个模块直接访问另一模块的内容,则称这两个模块为内容耦合。若在程序中出现下列情况之一,则说明两个模块之间发生了内容耦合:

    一个模块直接访问另一个模块的内部数据。 一个模块不通过正常入口而直接转入到另一个模块的内部。 两个模块有一部分代码重叠(该部分代码具有一定的独立功能)。 一个模块有多个入口。 内容耦合可能在汇编语言中出现。大多数高级语言都已设计成不允许出现内容耦合。这种耦合的耦合性最强,模块独立性最弱。 

六大设计原则

  • 单一职责原则 定义:就一个类而言,应该仅有一个引起它变化的原因。
  • 开放封闭原则 定义:类、模块、函数等应该是可以拓展的,但是不可修改。
  • 里氏替换原则 定义:所有引用基类(父类)的地方必须能透明地使用其子类的对象。由于使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象。子类的所有方法必须在父类中声明,或子类必须实现父类中声明的所有方法。
  • 依赖倒置原则 定义:高层模块不应该依赖低层模块,两者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。模块间的依赖通过抽象发生,实现类之间不发生直接依赖关系,其依赖关系是通过接口或者抽象类产生的。如果类与类直接依赖细节,那么就会直接耦合。
  • 迪米特原则 定义:一个软件实体应当尽可能少地与其他实体发生相互作用。简言之,就是通过引入一个合理的第三者来降低现有对象之间的耦合度。
  • 接口隔离原则 定义:一个类对另一个类的依赖应该建立在最小的接口上。建立单一接口,不要建立庞大臃肿的接口;尽量细化接口,接口中的方法尽量少。

创建型设计模式

单例模式

一、饿汉单例模式,实例随类初始化一起加载,无线程安全问题。特点是类加载慢,访问速度快。如果未使用,就会有内存浪费。

public class Singleton {
    private static Singleton instance = new Singleton();

    private Singleton() {
    }
    public static Singleton getInstance() {
        return instance;
    }
}

二、懒汉线程安全模式,保证线程安全,需要时才进行对象实例化,但是每次获取实例都需要进行同步,也会增大开销。

public class Singleton {
    private static Singleton instance;

    private Singleton() {

    }

    public static synchronized Singleton getinstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

三、静态内部单例模式,只有第一次调用getinstance方法时才会加载holder类,初始化instance。

public class Singleton {
    private Singleton() {
    }

    public static Singleton getInstance() {
        return SingletonHolder.sInstance;
    }

    private static class SingletonHolder {
        private static final Singleton sInstance = new Singleton();
    }
}

四、枚举单例,默认线程安全。

public enum Singleton {
    INSTANCE;

    public void doSomeThing() {
    }
}

杜绝反序列化生成另一个实例,可以将readResolve()返回值设为singleton对象。

使用场景

  • 工具类
  • 项目共享全局资源
  • 实现I/O或者数据库等资源的操作

工厂模式

简单工厂模式,用来说明工厂方法模式。

  • Factory:工厂类,这是简单工厂模式的核心,它负责实现创建所有实例的内部逻辑。工厂类的创建产品类的方法可以被外界直接调用,创建所需的产品对象。
  • IProduct:抽象产品类,这是简单工厂模式所创建的所有对象的父类,它负责描述所有实例所共有的公共接口。
  • Product:具体产品类,这是简单工厂模式的创建目标。 由工厂类,根据使用者的参数来决定创建哪一个产品实现类:
public class ComputerFactory {
    public static Computer createComputer(String type) {
        Computer mComputer = null;
        switch (type) {
            case "lenovo":
                mComputer = new LenovoComputer();
                break;
            case "hp":
                mComputer = new HpComputer();
                break;
            case "asus":
                mComputer = new AsusComputer();
                break;
        }
        return mComputer;
    }
}

缺点,每次需要新增一个产品实现类时,都需要去修改工厂类里的方法。

工厂方法模式

ConcreateFactory类里通过反射来创建对应的产品类,具体创建哪一个由调用方去传入Class来确定。

public class GDComputerFactor extends ComputerFactory {
    @Override
    public <T extends Computer> T createComputer(Class<T> clz) {
        Computer computer = null;
        String classname = clz.getName();
        try {
            //通过反射来生产不同厂家的计算机
            computer = (Computer) Class.forName(classname).newInstance();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return (T) computer;
    }
}

建造者模式

使用场景:

  • 当创建复杂对象的算法应该独立于该对象的组成部分以及它们的装配方式时。
  • 相同的方法,不同的执行顺序,产生不同的事件结果时。
  • 多个部件或零件都可以被装配到一个对象中,但是产生的运行结果又不相同时。
  • 产品类非常复杂,或者产品类中的调用顺序不同而产生了不同的效能。
  • 在创建一些复杂的对象时,这些对象的内部组成构件间的建造顺序是稳定的,但是对象的内部组成构件面临着复杂的变化。

核心为Builder实现类,里面由Director导演类来调用来确定建造类的参数,最后调用create()创建一个对象。这个过程对外部不可见。

public class MoonComputerBuilder extends Builder {
    private Computer mComputer = new Computer();

    @Override
    public void buildCpu(String cpu) {
        mComputer.setmCpu(cpu);
    }

    @Override
    public void buildMainboard(String mainboard) {
        mComputer.setmMainboard(mainboard);
    }

    @Override
    public void buildRam(String ram) {
        mComputer.setmRam(ram);
    }

    @Override
    public Computer create() {
        return mComputer;
    }
}

优点:

使用建造者模式可以使客户端不必知道产品内部组成的细节。具体的建造者类之间是相互独立的,容易扩展。由于具体的建造者是独立的,因此可以对建造过程逐步细化,而不对其他的模块产生任何影响。

缺点:

产生多余的Build对象以及导演类。

结构型设计模式

代理模式

在代理模式中,存在三个角色:

  1. 抽象主题(Subject):定义了真实主题和代理主题的公共接口,这样在任何使用真实主题的地方都可以使用代理主题。
  2. 真实主题(RealSubject):是实际需要被代理的对象,它定义了具体的业务逻辑。
  3. 代理主题(Proxy):持有对真实主题的引用,并可以在调用真实主题的方法前后添加额外的逻辑。 示例代码:
interface Service {
    void doService();
}
class RealService implements Service {

    @Override
    public void doService() {
        System.out.println("执行真实服务");
    }
}
class StaticProxy implements Service {

    private Service realService;

    public StaticProxy(Service realService) {
        this.realService = realService;
    }

    @Override
    public void doService() {
        System.out.println("在执行服务前的额外操作");
        realService.doService();
        System.out.println("在执行服务后的额外操作");
    }
}
public class StaticProxyDemo {
    public static void main(String[] args) {
        Service realService = new RealService();
        Service proxy = new StaticProxy(realService);
        proxy.doService();
    }
}

在上述代码中:

  • 定义了一个Service接口,RealService类实现了该接口。
  • StaticProxy类也实现了Service接口,作为静态代理类。它持有一个Service类型的对象realService,在doService方法中可以在调用真实对象的方法前后添加额外的操作。
  • 在main方法中创建了真实服务对象,并将其传递给代理对象,然后调用代理对象的方法。

动态代理

上面是静态代理,即在编译的时候,代理类的class文件就确定了。动态代理则是在运行时通过反射来动态生成代理类对象。Java 提供了动态的代理接口 InvocationHandler,实现该接口需要重写 invoke() 方法。

object DynamixProxyDemo {

    interface IShop {
        fun buy(p:Int)
    }

    class RealShop : IShop {
        override fun buy(number:Int) {
            Log.i(GLOBAL_TAG, "DynamicProxy RealShop: BUY $number HAT!")
        }
    }

    class DynamicShopProxy(private val shop: IShop) : InvocationHandler {
        override fun invoke(proxy: Any?, method: Method?, args: Array<out Any>?) =
            method?.invoke(shop, args?.get(0) as Int)
    }

    fun entrance() {
        val realShop = RealShop()
        val shopProxy: IShop = Proxy.newProxyInstance(
            realShop.javaClass.classLoader,
            Array(1) { IShop::class.java },
            DynamicShopProxy(realShop)
        ) as IShop
        shopProxy.buy(5)
    }
}

通过Proxy.newProxyInstance来生成动态代理类,强转调用disomething方法,会调用到DynamicProxyHandler的invoke方法。需要注意参数传递,有参无参,类型转换。

使用场景和优点

  • 远程代理:为一个对象在不同的地址空间提供局部代表,这样系统可以将 Server 部分的实现隐藏。
  • 虚拟代理:使用一个代理对象表示一个十分耗费资源的对象并在真正需要时才创建。
  • 安全代理:用来控制真实对象访问时的权限。一般用于真实对象有不同的访问权限时。
  • 智能指引:当调用真实的对象时,代理处理另外一些事,比如计算真实对象的引用计数,当该对象没有引用时,可以自动释放它;或者访问一个实际对象时,检查是否已经能够锁定它,以确保其他对象不能改变它。

代理模式的优点

  • 真实主题类就是实现实际的业务逻辑,不用关心其他非本职的工作。
  • 真实主题类随时都会发生变化;但是因为它实现了公共的接口,所以代理类可以不做任何修改就能够使用。

装饰模式

大体和代理模式类似,但是装饰者在调用实现类的时候,可以插入自己实现的其他方法。

  • Component:抽象组件,可以是接口或是抽象类,被装饰的最原始的对象。
  • ConcreteComponent:组件具体实现类。Component的具体实现类,被装饰的具体对象。
  • Decorator:抽象装饰者,从外类来拓展Component类的功能,但对于Component来说无须知道Decorator的存在。在它的属性中必然有一个private变量指向Component抽象组件。
  • ConcreteDecorator:装饰者的具体实现类。

抽象装饰者:

public abstract class Master extends Swordsman {
    private Swordsman mSwordsman;

    public Master(Swordsman mSwordsman) {
        this.mSwordsman = mSwordsman;
    }

    @override
    public void attackMagic() {
        mSwordsman.attackMagic();
    }
}

想扩展组件实现类的装饰者:

public class HongQiGong extends Master {
    public HongQiGong(Swordsman mSwordsman) {
        super(mSwordsman);
    }

    public void teachAttackMagic() {
        System.out.println("洪七公教授打狗棒法");
        System.out.println("杨过使用打狗棒法");
    }

    @override
    public void attackMagic() {
        super.attackMagic();
        teachAttackMagic();
    }
}

优点:

  • 通过组合而非继承的方式,动态地扩展一个对象的功能,在运行时选择不同的装饰器,从而实现不同的行为。
  • 有效避免了使用继承的方式扩展对象功能而带来的灵活性差、子类无限制扩张的问题。
  • 具体组件类与具体装饰类可以独立变化,用户可以根据需要增加新的具体组件类和具体装饰类,在使用时再对其进行组合,原有代码无须改变,符合“开放封闭原则”。

缺点:

  • 因为所有对象均继承于Component,所以如果Component内部结构发生改变,则不可避免地影响所有子类(装饰者和被装饰者)。如果基类改变,则势必影响对象的内部。
  • 比继承更加灵活机动的特性,也同时意味着装饰模式比继承更加易于出错,排错也很困难。对于多次装饰的对象,调试时寻找错误可能需要逐级排查,较为烦琐。所以,只在必要的时候使用装饰模式。
  • 装饰层数不能过多,否则会影响效率。

Kotlin的扩展函数是一种更为方便的实现方式。从语言层面,生成后缀加Kt的Java类,里面生成对应的static函数。

外观模式

  • Facade:外观类,知道哪些子系统类负责处理请求,将客户端的请求代理给适当的子系统对象。
  • Subsystem:子系统类,可以有一个或者多个子系统。实现子系统的功能,处理外观类指派的任务,注意子系统类不含有外观类的引用。

实际上就是对其他类的封装,多次调用综合成一个方法。

/**
 * 外观类张无忌
 */
public class ZhangWuji {
    private JingMai jingMai;
    private ZhaoShi zhaoShi;
    private NeiGong neiGong;

    public ZhangWuJi() {
        jingMai = new JingMai();
        zhaoShi = new ZhaoShi();
        neiGong = new NeiGong();
    }

    // 使用乾坤大挪移
    public void Qiankun() {
        jingMai.jingmai();//开启经脉
        neiGong.Qiankun();//使用内功乾坤大挪移
    }

    //使用七伤拳
    public void QiShang() {
        jingMai.jingmai();//开启经脉
        neiGong.JiuYang();//使用内功九阳神功
        zhaoShi.QiShangQuan();//使用招式七伤拳
    }
}

张无忌使用的一些技能,综合了其他子系统类的若干个其他方法。

优点:

  • 减少系统的相互依赖,所有的依赖都是对外观类的依赖,与子系统无关。
  • 对用户隐藏了子系统的具体实现,减少用户对子系统的耦合。这样即使具体的子系统发生了变化,用户也不会感知到。
  • 加强了安全性,子系统中的方法如果不在外观类中开通,就无法访问到子系统中的方法。

缺点:

  • 不符合开放封闭原则。如果业务出现变更,则可能要直接修改外观类。

享元模式

使用共享对象有效地支持大量细粒度的对象。要求细粒度对象,那么不可避免地使得对象数量多且性质相近。

这些对象分为两个部分:内部状态和外部状态。

内部状态是对象可共享出来的信息,存储在享元对象内部并且不会随环境的改变而改变;而外部状态是对象依赖的一个标记,它是随环境改变而改变的并且不可共享的状态。

例如商城系统,商品对象维护一份即可,不随每个客户端请求查看而重新创建一个。

public class Goods implements IGoods {
    private String name;//名称
    private String version;//版本
    Goods(String name) {
        this.name = name;
    }
    @Override
    public void showGoodsPrice(String version) {
        if (version.equals("32G")) {
            System.out.println("价格为5199元");
        } else if (version.equals("128G")) {
            System.out.println("价格为5999元");
        }
    }
}

name为内部状态,用来标记自身,不变化。version为外部状态,由外部传入,实时变化。

工厂:

public class GoodsFactory {
    private static Map<String, Goods> pool = new HashMap<String, Goods>();

    public static Goods getGoods(String name) {
        if (pool.containsKey(name)) {
            System.out.println("使用缓存,key为:" + name);
            return pool.get(name);
        } else {
            Goods goods = new Goods(name);
            pool.put(name, goods);
            System.out.println("创建商品,key为:" + name);
            return goods;
        }
    }
}

pool里符合要求的对象已存在,直接复用,没有才创建。

使用场景:

  • 系统中存在大量的相似对象。
  • 需要缓冲池的场景。

行为型设计模式

策略模式

当我们写代码时总会遇到一种情况,就是我们会有很多的选择,由此衍生出很多的if…else,或者case。

如果每个条件语句中包含了一个简单的逻辑,那还比较容易处理;但如果在一个条件语句中又包含了多个条件语句,就会使得代码变得臃肿,维护的成本也会加大,这显然违背了开放封闭原则。

策略模式是一种行为设计模式,它定义了一系列算法,并将每一个算法封装起来,使它们可以相互替换。策略模式让算法的变化独立于使用算法的客户端。

demo:

public class ZhangWuji {
    public static void main(String[] args) {
        Context context;
//张无忌遇到对手宋青书,采用对较弱对手的策略
        context = new Context(new WeakRivalStrategy());
        context.fighting();
//张无忌遇到对手灭绝师太,采用对普通对手的策略
        context = new Context(new CommonRivalsticategy());
        context.fighting();
//张无忌遇到对手成昆,采用对强大对手的策略
        context = new Context(new StrongRivalStrcategy());
        context.fighting();
    }
}

策略模式的使用场景和优缺点

  • 使用场景:

对客户隐藏具体策略(算法)的实现细节,彼此完全独立。 针对同一类型问题的多种处理方式,仅仅是具体行为有差别时。 在一个类中定义了很多行为,而且这些行为在这个类里的操作以多个条件语句的形式出现。策略模式将相关的条件分支移入它们各自的 Strategy 类中,以代替这些条件语句。

  • 优点:

使用策略模式可以避免使用多重条件语句。多重条件语句不易维护,而且易出错。 易于拓展。当需要添加一个策略时,只需要实现接口就可以了。

  • 缺点:

每一个策略都是一个类,复用性小。如果策略过多,类的数量会增多。上层模块必须知道有哪些策略,才能够使用这些策略,这与迪米特原则相违背。

模板方法模式

在软件开发中,有时会遇到类似的情况:某个方法的实现需要多个步骤,其中有些步骤是固定的;而有些步骤并不固定,存在可变性。为了提高代码的复用性和系统的灵活性,可以使用模板方法模式来应对这类情况。

public abstract class AbstractSwordsman {
    //该方法为final,防止算法框架被覆写
    public final void fighting() {
        //运行内功,抽象方法
        neigong();
        //调整经脉,具体方法
        meridian();
        //如果有武器,则准备武器
        if (hasWeapons()) {//2
            weapons();
        }
        //使用招式
        moves();
        //钩子方法
        hook();//1
    }
    //空实现方法
    protected void hook() {
    }
    protected abstract void neigong();
    protected abstract void weapons();
    protected abstract void moves();
    protected void meridian() {
        System.out.println("开启正经与奇经");
    }
    /**
     * 是否有武器,默认是有武器的,钩子方法
     *
     * @return
     */
    protected boolean hasWeapons() {
        return true;
    }
}

定义:定义一个操作中的算法框架,而将一些步骤延迟到子类中,使得子类不改变一个算法的结构即可重定义算法的某些特定步骤。

这个抽象类包含了3种类型的方法,分别是抽象方法、具体方法和钩子方法。抽象方法是交由子类去实现的,具体方法则是父类实现了子类公共的方法。在上面的例子中就是武侠开启经脉的方式都一样,所以就在具体方法中实现。

钩子方法则分为两类:第一类在上面代码注释1 处,它有一个空实现的方法,子类可以视情况来决定是否要覆盖它;第二类在注释 2 处,这类钩子方法的返回类型通常是 boolean 类型的,其一般用于对某个条件进行判断,如果条件满足则执行某一步骤,否则将不执行。

观察者模式

观察者模式又被称为发布-订阅模式,属于行为型设计模式的一种,是一个在项目中经常使用的模式。

它的定义如下。

定义:定义对象间一种一对多的依赖关系,每当一个对象改变状态时,则所有依赖于它的对象都会得到通知并被自动更新。

  • Subject:抽象主题(抽象被观察者)。抽象主题角色把所有观察者对象保存在一个集合里,每个主题都可以有任意数量的观察者。抽象主题提供一个接口,可以增加和删除观察者对象。
  • ConcreteSubject:具体主题(具体被观察者)。该角色将有关状态存入具体观察者对象,在具体主题的内部状态发生改变时,给所有注册过的观察者发送通知。
  • Observer:抽象观察者,是观察者的抽象类。它定义了一个更新接口,使得在得到主题更改通知时更新自己。
  • ConcrereObserver:具体观察者,实现抽象观察者定义的更新接口,以便在得到主题更改通知时更新自身的状态。
public class Client {
    public static void main(String[] args) {
        SubscriptionSubject mSubscriptionSubject = new SubscriptionSubject();
        //创建微信用户
        Weixinuser userl = new WeixinUser("杨影枫");
        WeixinUser user2 = new WeixinUser("月眉儿");
        WeixinUser user3 = new WeixinUser("紫轩");
        //订阅公众号
        mSubscriptionSubject.attach(userl);
        mSubscriptionSubject.attach(user2);
        mSubscriptionSubject.attach(user3);
        //公众号更新发出消息给订阅的微信用户
        mSubscriptionSubject.notify("刘望舒的专栏更新了");
    }
}

被观察者维护一个列表,用于添加删除观察者,在变化时给所有的观察者发送通知。

观察者模式的使用场景和优缺点

  • 使用场景:

关联行为场景。需要注意的是,关联行为是可拆分的,而不是“组合”关系。 事件多级触发场景。跨系统的消息交换场景,如消息队列、事件总线的处理机制。

  • 优点:

观察者和被观察者之间是抽象耦合,容易扩展。方便建立一套触发机制。

  • 缺点:

在应用观察者模式时需要考虑一下开发效率和运行效率的问题。程序中包括一个被观察者、多个观察者,开发、调试等内容会比较复杂,而且在 Java 中消息的通知一般是顺序执行的,那么一个观察者卡顿,会影响整体的执行效率,在这种情况下,一般会采用异步方式。

小结

需要注意的是学习设计模式最忌讳生搬硬套,为了设计模式而设计。设计模式主要解决的问题就是设计模式的六大原则,只要我们设计的代码遵循这六大原则,那么就是优秀的代码。

【Android进阶】BLE通信一般流程

【Android进阶】BLE通信一般流程

本文介绍了Android平台上连接手环等BLE设备一般的通信流程

在当今的物联网(IoT)时代,我们的智能手机早已不仅仅是通讯工具,更是连接和控制身边智能硬件的中枢。无论是追踪你运动数据的智能手环,监测你睡眠质量的智能床垫,还是让你告别钥匙的智能门锁,它们与 Android 设备之间高效、可靠的通信桥梁,正是低功耗蓝牙 (Bluetooth Low Energy, BLE) 技术。

什么是 BLE?

BLE 低功耗蓝牙(通常也被称为 Bluetooth Smart)是专为物联网应用设计的无线通信协议。它继承了传统蓝牙的可靠性,但在功耗上做到了极致优化 。不同于传统蓝牙需要持续、高带宽的数据流,BLE 的核心在于快速连接、发送少量数据后迅速休眠,这使得它成为电池供电的小型设备的首选。对于 Android 开发者来说,熟练掌握如何利用 Android 系统提供的 API 与这些 BLE 设备进行交互,是构建现代移动应用的关键技能。

丰富的应用场景

BLE 连接技术已经渗透到我们生活的方方面面,带来了无限的创新可能:

  1. 健康与运动追踪: 智能手表、心率带、运动传感器等将实时数据传输到你的 Android App,进行分析和可视化。
  2. 智能家居与控制: 通过手机控制智能灯泡、温度计、门锁、以及各种传感器。
  3. 资产定位与寻物: 利用 iBeacon 或 Eddystone 等技术实现室内导航、商家推送,以及通过小巧的蓝牙标签寻找丢失的物品。

本篇博客将介绍Android设备和BLE设备通信的几个关键流程,扫描连接发现服务,并最终实现与 BLE 设备的高效数据交互

核心概念先行:

  • 手机 (Central / 中心设备): 主动扫描并连接其他设备的设备,在BLE协议中称为 中心设备 。手机、电脑等通常扮演这个角色。
  • BLE设备 (Peripheral / 外围设备): 被动广播自身信息,等待被连接的设备,称为外围设备。如智能手环、心率带、防丢器等。
  • GATT (Generic Attribute Profile): 这是连接建立后,双方通信所遵循的核心协议。它定义了一个 服务-特征值 的数据结构。
    • 服务: 一个独立的功能模块。例如,一个“心率服务”。
    • 特征: 服务下的具体数据点。它是实际读写操作的对象。例如,在“心率服务”下,会有 “心率测量特征” (用于读取心率数据)和 “心率位置特征” (用于写入或通知佩戴位置)。
    • 属性: 特征、服务等都被称为属性,每个属性都有一个唯一的标识符。

1. 寻找设备 广播与扫描

这个阶段的目标是让手机发现BLE设备的存在

1.1 外围设备广播

外围设备会周期性地(例如每秒100次)向周围环境 发送广播数据包(Advertising Packets)。这些数据包包含少量信息,这就是广播报文。

广播报文内容:

  • 设备地址: 类似MAC地址,是设备的唯一标识符。
  • 设备名称: 可读的名称,如 “MI_Band”。
  • 发射功率: 用于粗略的距离估算。
  • 服务UUIDs: 设备所支持的主要服务的列表。这是手机判断设备类型的关键(例如,看到“心率服务”的UUID,就知道这是个心率设备)。
  • 制造商特定数据: 设备厂商可以自定义放入一些数据,如电池电量、硬件版本等。

1.2 中心设备扫描

手机(通常作为中央设备 Central)处于扫描状态,App通过操作系统提供的蓝牙API启动蓝牙扫描,手机的蓝牙芯片会监听来自周围 BLE 设备的广播数据包,在同样的广播通道上监听这些广播报文。

手机收到数据包后,会将其解析并回调给应用程序,提取出上述广播的信息,并在App的扫描结果列表中显示出来(例如,显示“发现设备:MI_Band”)。

此时,手机知道了BLE设备的存在和基本信息,但双方还未建立正式连接。这是设备间建立联系的第一步。

1.3 广播细节

手机(Observer)设备B(Advertiser) 建立连接之前,设备B需要先进行广播,即设备B不断发送如下广播信号,t 为广播间隔。

每发送一次广播包,我们称其为一次广播事件(advertising event),因此t也称为广播事件间隔。广播事件是有一个持续时间的,蓝牙芯片只有在广播事件期间才打开射频模块,这个时候功耗比较高,其余时间蓝牙芯片都处于idle状态,因此平均功耗非常低,以Nordic nRF52810为例,每1秒钟发一次广播,平均功耗不到11uA。

每一个广播事件包含三个广播包,即分别在37/38/39三个射频通道上同时广播相同的信息,即真正的广播事件是下面这个样子的。

广播事件

设备B不断发送广播信号给手机(Observer),如果手机不开启扫描窗口,手机是收不到设备B的广播的,如下图所示,不仅手机要开启射频接收窗口,而且 只有手机的射频接收窗口跟广播发送的发射窗口匹配成功 ,而且 广播射频通道和手机扫描射频通道是同一个通道 ,手机才能收到设备B的广播信号。

也就是说,如果设备B在37通道发送广播包,而手机在扫描38通道,那么即使他们俩的射频窗口匹配,两者也是无法进行通信的。

由于这种匹配成功是一个概率事件,因此手机扫到设备B也是一个概率事件,也就是说,手机有时会很快扫到设备B,比如只需要一个广播事件,手机有时又会很慢才能扫到设备B,比如需要10个广播事件甚至更多。

避免信道干扰

为了避免与其他无线设备(主要是Wi-Fi)的干扰,BLE广播事件的三个广播包分别在 37/38/39 三个射频通道上广播,这三个通道分别避开了Wi-Fi信道 1/11/6 的下边缘和上边缘。

  • 频率冲突:Wi-Fi主要工作在2.4GHz频段,而这个频段也正是蓝牙(包括BLE)的工作频段。Wi-Fi将其频段划分为多个信道,例如常用的1, 6, 11信道。
    • Wi-Fi信道1 的中心频率是 2.412 GHz
    • Wi-Fi信道6 的中心频率是 2.437 GHz
    • Wi-Fi信道11 的中心频率是 2.462 GHz
  • BLE信道的频率:
    • BLE信道37: 2.402 GHz
    • BLE信道38: 2.426 GHz
    • BLE信道39: 2.480 GHz

你可能会注意到,BLE总共有40个物理信道(0-39)。37、38、39用于广播,那么剩下的0-36信道用于什么呢?

  • 数据信道:在BLE连接建立之后,通信双方会使用 自适应跳频技术 ,在0-36这37个信道上进行数据传输。
  • 跳频的意义:连接后使用跳频,是为了在数据传输阶段也能 动态地避开瞬时干扰 。主从设备会共同协商,跳过那些信噪比差、干扰大的信道,从而在连接状态下也能维持一个稳定、高效的数据链路。

2. 建立连接

当用户在手机 App 上选择一个设备后,就会开始连接过程。

手机请求:

  • 动作: 手机 App 调用系统 API,向选定的 BLE 设备发送连接请求(Connection Request)。
  • 信息: 请求中会包含连接参数,如连接间隔(Connection Interval)、从机延迟(Slave Latency)和超时时间(Supervision Timeout),这些参数定义了连接后数据交换的频率和容错能力。即双方会协商一套通信参数,如连接间隔(设备多久通信一次,影响功耗和响应速度)、从机延迟等。

BLE 设备响应:

  • 动作: BLE 设备从广播状态切换到连接状态,并接受连接请求。
  • 结果: 双方建立了一个双向的、独占的连接。从此刻起,设备停止广播,并且只有这个手机能与它通信。双方进入链路层(Link Layer)的数据交换阶段。在连接状态下,通信会从之前的3个广播通道切换到37个数据通道,并通过一种自适应跳频技术来避免无线干扰,保证通信稳定。

3. 服务发现

连接建立后,手机需要知道设备上有什么功能。

BLE 使用 GATT (Generic Attribute Profile) 规范来组织数据。数据被组织成服务和特性,每个特性都有一个 UUID 标识。

手机作为 GATT 客户端,会自动向BLE设备发送一个 服务发现请求

BLE设备会将其内部的所有服务,以及每个服务下的所有特征,像一个文件目录一样完整地返回给手机。

手机端的蓝牙协议栈会解析这个“目录”,并建立一个本地的GATT数据库。手机 App 知道了设备的全部功能结构(UUID 和属性),就可以通过查询这个数据库来知道可以对设备进行哪些操作。

4. 配对绑定

安全校验和配对(Bonding/Pairing) 在手机和 BLE 设备之间的通信中不是必须的。是否需要配对,完全取决于 BLE 设备上的特性(Characteristic) 的配置。

很多 BLE 服务中的特性(例如,电池电量、设备名称、简单的通知开关)被配置为可以进行未加密或无需身份验证的读取和写入操作。对于这些特性,手机可以直接在连接建立后进行服务发现,然后直接进行读写,不需要经过配对(Pairing)或绑定(Bonding)的复杂流程。

配对 是用于建立加密和身份验证的链路,它在以下情况是必须的:

  • 敏感数据传输: 当传输的数据具有隐私性或敏感性时(如医疗数据、GPS 位置、个人运动记录)。
  • 控制关键功能: 当手机需要控制设备的关键、有影响的功能时(如智能门锁的开关、支付授权、修改固件设置)。
  • 需要用户身份确认: 设备的某个特性被配置为需要加密或身份验证权限才能访问。当手机尝试读写这个受保护的特性时,BLE 栈(Stack)会强制触发配对流程。

配对/绑定 流程

配对和绑定是为了建立信任关系,并交换安全密钥,以便在后续连接中能进行加密通信。安全性和配对通常发生在服务发现之后,或在第一次读写受保护的特性时触发。如访问加密、身份验证的特性时,系统会自动触发配对流程。

通信双方协商安全级别和配对方式(如 Just WorksPasskey EntryNumeric Comparison 等)。配对过程中,手机可能会弹出配对请求框,显示一个随机生成的6位数字码。用户需要在设备上确认这个数字码,或者简单地点击 “配对” 按钮。然后双方会交换密钥,建立信任关系。

配对成功后,双方交换 长期密钥 (LTK) ,并将其存储起来(称为绑定)。绑定后,后续连接可以直接使用 LTK 建立加密链路,无需重复配对。

5. 通信 - 基于GATT的数据交互

所有通信都是通过 对特征的读写 操作完成的。特征有不同的属性来控制其行为:

  • Read: 手机可以主动读取特征的值。例如,读取一次当前的电池电量
  • Write: 手机可以向特征写入数据(控制设备)。例如,向设备发送一个“寻找手环”的命令,手环收到之后就会震动
  • Notify / Indicate: 这是BLE通信中最重要、最节能的模式。设备可以在数据变化时,主动向手机推送数据,而手机无需不停地询问。
    • Notify 通知: 不可靠通知,手机不回复确认。
    • Indicate 指示: 可靠通知,设备会等待手机的确认后再发送下一条。

这个流程是所有基于 BLE 的应用通信的基础。在 Android 平台,可以在 Android 的 BluetoothLeScanner 类中找到扫描相关的 API,在 BluetoothGatt 类中找到连接、服务发现和数据读写相关的 API。

如何传输大段数据

低功耗蓝牙 (BLE) 在设计之初是为了低功耗和传输少量数据而优化的。因此,它在传输大段数据时会有一些限制和特定的处理方式。

BLE 数据传输基于 GATT (Generic Attribute Profile),数据是封装在 ATT (Attribute Protocol) 数据包中传输的。

ATT 最大传输单元 (ATT_MTU) 是应用层一次可以发送或接收的最大数据包大小。

  • 在 BLE 4.0/4.1 中,默认的 ATT_MTU 是 23 字节。这意味着,在一个 NotificationIndicationWrite 请求中,用户数据(特征值)部分最大只有 20 字节(因为 3 字节用于 ATT 头部, 23 - 3 = 20)。
  • 在 BLE 4.2 及更高版本中,设备可以协商更大的 ATT_MTU(例如 250 字节以上),这显著提高了单次传输的效率。

物理层最大传输单元 (PDU) 的限制比 ATT_MTU 更底层。在 BLE 5.0 中,数据包的 PDU 最大可以达到 257 字节。

分包和重组 & 缩短传输间隔

由于单次 ATT 传输的有效载荷(Payload)有限(通常至少 20 字节,即使协商了更大的 MTU,也通常小于 512 字节),传输大段数据(如文件、图片等)就必须依赖应用层进行分包和重组

a. 应用层分包 (Segmentation) 和重组 (Reassembly)

发送端分包 将完整的原始数据(例如 1MB 的文件)切分成多个小的分包。每个分包的大小取决于当前连接协商的 ATT_MTU 所允许的最大特征值大小。

1. 拆分小段数据

每一段数据包 添加头部 每个分包都需要添加一个自定义的应用层头部(Header)。这个头部通常包含。

  • 序号/索引 (Sequence Number) 用于接收端按正确顺序重组数据。
  • 总包数 (Total Packets)数据长度 (Total Length) 帮助接收端判断是否接收完整。
  • 校验和 (Checksum):用于验证分包数据的完整性。
2. 逐包发送

发送端通过 BLE 的 NotificationWrite Without Response (取决于你的设计)将这些分包一个接一个地发送出去。

3. 接收端重组

接收端根据每个分包中的 序号 ,将收到的数据段缓存并按照正确的顺序拼接起来,直到所有分包都收到,形成完整的原始数据。再做一次 完整性检查 通过头部中的 总包数/长度校验和 来验证接收到的完整数据的正确性。

b. 提高传输效率的关键技术

为了加快传输速度,应该尽可能利用 BLE 协议栈提供的优化:

  1. 协商更大的 MTU:在连接建立后,立即进行 MTU 协商(例如 Android/iOS 系统会自动进行,或者应用层手动触发)。将 MTU 增大到 256 甚至 517 字节,可以成倍减少分包的数量和 ATT 事务的次数。
  2. 数据长度扩展 (DLE):在 BLE 4.2 及更高版本中引入,它允许将物理层 PDU 的有效载荷从默认的 27 字节增加到 257 字节。这直接支持了 MTU 扩展。
  3. 使用 Write Without ResponseNotification:这两种方法是非确认机制,发送端发送数据后不需要等待接收端的确认 (ACK),因此可以连续快速发送。这是传输大段数据的首选方式,但需要应用层自行处理丢包或错误(通常通过重传机制)。
  4. 优化连接间隔 (Connection Interval):连接间隔越短,收发数据的频率越高,吞吐量也越大。但要注意,极短的连接间隔会增加功耗。你需要找到一个吞吐量和功耗之间的平衡点。

Android项目简单实践

1.扫描设备

连接BLE设备,首先需要扫描。Android SDK中使用 BluetoothLeScanner 对象来执行扫描的动作:

private BluetoothLeScanner bluetoothLeScanner;

bluetoothLeScanner = bluetoothAdapter.getBluetoothLeScanner();

bluetoothLeScanner.startScan(scanCallback);

由于扫描是一个异步的过程,所以这里需要传入一个回调接口,我们在回调接口去获取扫描的结果:

public ScanCallback scanCallback = new ScanCallback() {
        @Override
        public void onScanResult(int callbackType, ScanResult result) {
            super.onScanResult(callbackType, result);
            ScanRecord record = result.getScanRecord();
            BluetoothDevice device = result.getDevice();
            String deviceName = record.getDeviceName();
            Log.d(TAG, "record name:" + deviceName);
            Log.d(TAG, "ServiceUuids:" + record.getServiceUuids());

            if (TextUtils.isEmpty(deviceName)) {
                return;
            }

            if (deviceName.startsWith("XX")) {    //这里我们可以找出设备名以XX开头的BLE设备
              
                byte[] bytes = record.getBytes();    //这里可以获取整个广播的完整数据,包括协议头等
                for (int i = 0; i < bytes.length; i++) {
                    Log.d(TAG, "[" + i + "]:" + bytes[i]);
                }
                
                bluetoothLeScanner.stopScan(scanCallback);     //需要停止扫描
                              
            }
        }

    };

2.连接设备

在找到了“XX”名称的设备后,我们就可以发起GATT协议的连接了:

/**
 * 
 * @param autoConnect Whether to directly connect to the remote device (false) or to automatically connect as soon as the remote device becomes available (true).
 * @param callback GATT callback handler that will receive asynchronous callbacks.
*/
device.connectGatt(context, false, gattCallback);

第二个参数 autoConnect 如果是false代表仅发起本次连接,如果连接不上则会反馈连接失败;如果是true则表示只要这个远程的设备可用,那么底层协议栈就会自动去连接,并且第一次连接不上,也会继续去连接。

第三个参数 callback 是一个 关于GATT协议相关的回调接口 ,主要有GATT连接状态的回调、发现Service服务的回调、特征值(Characteristic)发生改变的回调、最大传输单元(MTU)改变的回调、物理层发送模式(PHY)改变回调等,如下:

BluetoothGattCallback gattCallback = new BluetoothGattCallback() {

        @Override
        public void onPhyUpdate(BluetoothGatt gatt, int txPhy, int rxPhy, int status) {
            super.onPhyRead(gatt, txPhy, rxPhy, status);
            Log.d(TAG, "onPhyUpdate txPhy:" + txPhy + "; rxPhy:" + rxPhy);
        }

        @Override
        public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) {
            super.onMtuChanged(gatt, mtu, status);
            Log.d(TAG, "onMtuChanged mtu:" + mtu + "; status:" + status);
        }

        @Override
        public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
            super.onConnectionStateChange(gatt, status, newState);
            Log.d(TAG, "onConnectionStateChange newState:" + newState);
            if (newState == BluetoothProfile.STATE_CONNECTED) {  //协议连接成功
                Log.d(TAG, "STATE_CONNECTED");
                bluetoothGatt = gatt;             
                bluetoothGatt.discoverServices();   //发现service服务
            } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {         //协议连接失败
                Log.d(TAG, "STATE_DISCONNECTED");
            }

        }
}

3.获取服务和特征

在GATT协议连接成功之后,就可以去发现从设备端提供了哪些Service服务,如上代码。

这是一个异步的过程,待从设备反馈了自己提供的服务之后,Android框架层会通过BluetoothGattCallback回调通知,如下:

BluetoothGattCallback gattCallback = new BluetoothGattCallback() {
    @Override
    public void onServicesDiscovered(BluetoothGatt gatt, int status) {
        super.onServicesDiscovered(gatt, status);
        List<BluetoothGattService> services = gatt.getServices();
        for (BluetoothGattService service : services) {
            Log.d(TAG, "UUID:" + service.getUuid().toString());
        }

        //1.根据UUID获取到服务
        mGattService = gatt.getService(UUID.fromString("0000ff00-0000-1000-8000-00805f9b34fb"));

        if (mGattService == null) {
            Log.w(TAG, "GattService is null!");
        } else {
            Log.i(TAG, "connect GattService");
            if (writeCharacteristic == null) {
                //2.获取一个特征(Characteristic),这是从设备定义好的,我通过这个Characteristic去写从设备感兴趣的值
                writeCharacteristic = mGattService
                        .getCharacteristic(UUID.fromString("0000ff02-0000-1000-8000-00805f9b34fb"));
            }
            if (readCharacteristic == null) {
                //3.获取一个主设备需要去读的特征(Characteristic),获取从设备发送过来的数据
                readCharacteristic = mGattService
                        .getCharacteristic(UUID.fromString("0000ff01-0000-1000-8000-00805f9b34fb"));

                //4.注册特征(Characteristic)值改变的监听
                bluetoothGatt.setCharacteristicNotification(readCharacteristic, true);
                List<BluetoothGattDescriptor> descriptors = readCharacteristic.getDescriptors();
                for (BluetoothGattDescriptor descriptor : descriptors) {
                    descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
                    bluetoothGatt.writeDescriptor(descriptor);
                }
            }

        }
    }
};

经过上述代码中的四个步骤,两个设备间已经可以发送和接收数据了。

4.通过特征(Characteristic)发送数据

把需要发送的数据设置到 writeCharacteristic,然后再调用 BluetoothGatt 的写入方法,即可完成数据的发送:

writeCharacteristic.setValue(datas);
bluetoothGatt.writeCharacteristic(writeCharacteristic);

5.读取数据

当从设备有数据发送到主设备之后,Android系统会回调 BluetoothGattCallbackonCharacteristicChanged 方法通知:

@Override
        public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
            super.onCharacteristicChanged(gatt, characteristic);
            UUID uuid = characteristic.getUuid();
            byte[] receiveData = characteristic.getValue();
            for (byte b : receiveData) {
                Log.d(TAG, "receiveData:" + Integer.toHexString(b));
            }
        }

三、注意事项

1.自动连接属性

connectGatt 方法的自动连接参数设置为true之后,连接建立了,这个时候如果是断开连接,如下:

bluetoothGatt.disconnect();

虽然在Android层面的 BluetoothGattCallback 接口会立刻反馈一个 STATE_DISCONNECTED 信号值,但是在数据链路层却还是处于连接的状态,连接并没有断开。

2.开启定位功能

现在Android最新的版本,需要开启定位才能使用BLE功能。

判断定位功能是否开启:

private boolean isLocationEnable(Context context) {
        LocationManager locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
        boolean networkProvider = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER);
        boolean gpsProvider = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER);
        if (networkProvider || gpsProvider) {
            return true;
        }
        return false;
    }

开启定位功能的方法:

LocationManager locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
            try {
                Field field = UserHandle.class.getDeclaredField("SYSTEM");
                field.setAccessible(true);
                UserHandle userHandle = (UserHandle) field.get(UserHandle.class);
                Method method = LocationManager.class.getDeclaredMethod(
                        "setLocationEnabledForUser",
                        boolean.class,
                        UserHandle.class);
                method.invoke(locationManager, true, userHandle);
            } catch (Exception e) {
            }

3.最大传输单元(MTU)的设置

Android默认的最大传输单元(MTU)是23个字节,除去报文头占用的3个字节,实际最大只能传递20个字节。当两个设备之间传递的数据长度超过20字节的时候,数据就会被截断,导致通信异常。

只有在GATT协议连接成功之后,才可以设置MTU值,最大MTU=512,如下:

bluetoothGatt.requestMtu(128);

4.从设备广播间隔影响连接

当Android协议栈(Host)给蓝牙芯片Chip发送一个连接的指令,芯片在收到之后,会在一定的时间内去接收从设备的广播,在收到广播之后才会发送连接请求给从设备;如果从设备的广播间隔设置不合理,就会导致芯片无法在限定的时间内收到广播,导致无法发送连接请求。

【Android进阶】Android项目架构演进

【Android进阶】Android项目架构演进

本文介绍了Android开发领域各种不同的架构演进历史

对于安卓开发者来说,理解并熟练运用架构模式是提升代码质量、可维护性和可测试性的关键。

我们总是在追求编写更清晰、更健壮、更易于维护的代码。而选择一个合适的应用架构,正是实现这一目标的基石。从经典的 MVC,到后来的 MVP、MVVM,再到如今函数式思想影响下的 MVI,安卓的架构模式一直在演进。

为了方便对比,我们设定一个极其简单的业务场景:

一个登录界面,包含输入用户名、密码的输入框和一个登录按钮。点击按钮后,模拟一个网络请求,根据结果(成功/失败)更新 UI。

MVC (Model-View-Controller)

MVC 是一个非常古老的 UI 架构模式,在安卓早期开发中,它是一种“天然”的结构。

  • Model (模型): 负责处理数据和业务逻辑。例如,网络请求、数据库操作、数据bean类。
  • View (视图): 负责展示 UI 界面,并将用户的操作(点击、输入)传递出去。在安卓中,这通常由 XML 布局文件和 Activity/Fragment 扮演。
  • Controller (控制器): 接收来自 View 的用户操作,调用 Model 处理业务逻辑,然后更新 View 的显示。Activity/Fragment 通常也承担了 Controller 的角色。

MVC的问题

在安卓中,Activity/Fragment 的职责过重,它既是 View 的一部分,又是 Controller。这 导致 View 和 Controller 紧密耦合 ,业务 逻辑和 UI 代码混杂 在一起,使得代码难以测试和维护。这就是我们常说的超大型Activity和Fragment。

简单代码实现

UserModel.kt (Model)

// M - Model: 负责业务逻辑和数据
data class User(val name: String)

object UserModel {
    // 模拟登录网络请求
    fun login(username: String, callback: (Result<User>) -> Unit) {
        // 模拟延时
        Thread.sleep(1000)
        if (username == "admin") {
            callback(Result.success(User("Administrator")))
        } else {
            callback(Result.failure(Exception("用户名或密码错误")))
        }
    }
}

activity_login.xml (View)

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp">

    <EditText
        android:id="@+id/et_username"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="Username"/>

    <Button
        android:id="@+id/btn_login"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Login"/>

    <ProgressBar
        android:id="@+id/progress_bar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:visibility="gone"/>
</LinearLayout>

LoginActivity.kt (View & Controller)

// V & C: Activity 同时扮演视图和控制器的角色
class LoginActivity : AppCompatActivity() {
    
    private lateinit var usernameEditText: EditText
    private lateinit var loginButton: Button
    private lateinit var progressBar: ProgressBar

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_login)

        usernameEditText = findViewById(R.id.et_username)
        loginButton = findViewById(R.id.btn_login)
        progressBar = findViewById(R.id.progress_bar)

        loginButton.setOnClickListener {
            handleLogin()
        }
    }

    private fun handleLogin() {
        showLoading()
        val username = usernameEditText.text.toString()

        // 控制器直接调用模型
        // 注意:这里为了简化在主线程调用,实际开发应使用协程或线程池
        Thread {
            UserModel.login(username) { result ->
                runOnUiThread {
                    hideLoading()
                    result.onSuccess { user ->
                        showSuccess("欢迎, ${user.name}!")
                    }.onFailure { error ->
                        showError(error.message ?: "登录失败")
                    }
                }
            }
        }.start()
    }

    private fun showLoading() {
        progressBar.visibility = View.VISIBLE
    }

    private fun hideLoading() {
        progressBar.visibility = View.GONE
    }

    private fun showSuccess(message: String) {
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }

    private fun showError(message: String) {
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }
}

MVP (Model-View-Presenter)

为了解决 MVC 中 Controller 和 View 的过度耦合问题,MVP 诞生了。它引入了一个新的角色:Presenter。

  • Model: 职责不变,处理数据和业务逻辑。
  • View: 职责更纯粹,只负责 UI 的渲染和事件的传递。Activity/Fragment 属于 View 层。它通常会实现一个接口,供 Presenter 调用。
  • Presenter: 作为 View 和 Model 之间的桥梁。它从 Model 获取数据,然后调用 View 接口的方法来更新 UI。Presenter 不持有任何 Android 框架的引用,这使得它很容易进行单元测试。

解决了一些MVC的痛点,View 和 Presenter 通过接口进行通信,实现了彼此的解耦。Presenter 持有 View 的接口引用,但不关心 View 的具体实现。

MVP的问题

主要有两点:

  • 接口爆炸: 为了清晰地定义View和Presenter之间的契约,你需要为每一个界面(或功能模块)定义至少两个接口: IViewIPresenter 。随着项目模块的增加,接口数量会急剧膨胀,导致代码文件数量非常多。
  • 繁琐的绑定与解绑: Presenter 需要持有 View 的引用,为了避免内存泄漏(尤其是在异步任务回调时),你必须在View(如Activity/Fragment)的生命周期方法(onCreate, onDestroy)中手动进行 attachView()detachView() 操作。这个过程是重复且容易出错的。

代码实现

LoginContract.kt (契约接口)

// 定义 View 和 Presenter 之间的契约
interface LoginContract {
    // View 必须实现的接口
    interface View {
        fun showLoading()
        fun hideLoading()
        fun showLoginSuccess(message: String)
        fun showLoginError(message: String)
    }

    // Presenter 必须实现的接口
    interface Presenter {
        fun login(username: String)
        fun onDestroy()
    }
}

LoginPresenter.kt (Presenter)

// P - Presenter: 不含任何 Android SDK 代码,纯 Kotlin/Java
class LoginPresenter(private var view: LoginContract.View?) : LoginContract.Presenter {

    // Presenter 持有 Model 的引用
    private val model = UserModel

    override fun login(username: String) {
        view?.showLoading()
        // 同样,这里简化处理,实际应在子线程
        Thread {
            model.login(username) { result ->
                // 回到主线程更新 UI
                (view as? AppCompatActivity)?.runOnUiThread {
                    view?.hideLoading()
                    result.onSuccess { user ->
                        view?.showLoginSuccess("欢迎, ${user.name}!")
                    }.onFailure { error ->
                        view?.showLoginError(error.message ?: "登录失败")
                    }
                }
            }
        }.start()
    }
    
    // 防止内存泄漏
    override fun onDestroy() {
        view = null
    }
}

LoginActivity.kt (View)

// V - View: 只负责 UI 展示和用户事件传递
class LoginActivity : AppCompatActivity(), LoginContract.View {

    private lateinit var presenter: LoginContract.Presenter
    // ... UI 控件声明 ...

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_login)
        
        presenter = LoginPresenter(this)
        // ... findViewById ...

        loginButton.setOnClickListener {
            presenter.login(usernameEditText.text.toString())
        }
    }

    override fun showLoading() {
        progressBar.visibility = View.VISIBLE
    }

    override fun hideLoading() {
        progressBar.visibility = View.GONE
    }

    override fun showLoginSuccess(message: String) {
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }

    override fun showLoginError(message: String) {
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }
    
    override fun onDestroy() {
        presenter.onDestroy()
        super.onDestroy()
    }
}

MVVM (Model-View-ViewModel)

MVVM 是 Google 官方推荐的架构模式,也是 Jetpack 组件(如 ViewModel, LiveData, DataBinding)的核心思想。

  • Model: 职责不变。
  • View: 职责依然是 UI 展示。但它不再被动地等待 Presenter 调用,而是主动观察 (Observe) ViewModel 中的数据变化来更新自己。
  • ViewModel: 类似于 Presenter,负责处理业务逻辑并持有数据。但它不直接引用 View。它通过暴露可观察的数据(如 LiveDataStateFlow)来通知 View 更新。ViewModel 的生命周期与 UI 控制器(Activity/Fragment)的配置更改无关,因此在屏幕旋转时数据不会丢失。

解决前面两代主流架构的痛点: 数据绑定 (Data Binding)生命周期感知 (Lifecycle-Aware)。View 和 ViewModel 通过可观察的数据流进行单向或双向绑定,实现了比 MVP 更彻底的解耦。

MVVM的问题

状态管理的复杂性(Lack of Unidirectional Data Flow):

  • 虽然 MVVM 实现了视图和数据之间的双向绑定,但在复杂的场景下,这可能导致数据流变得难以追踪。当一个数据模型在多个视图或组件之间共享时,任何一方的修改都可能影响到其他部分,使得状态的来源和变化路径变得模糊不清。

不一致的状态(Inconsistent State):

  • 在某些情况下,视图可能会从多个地方接收数据更新(例如,网络请求、本地数据库更新、用户输入)。当这些更新并非同步发生时,视图可能会进入一种不一致的状态,导致用户界面出现意料之外的行为或显示错误。

代码实现

LoginViewModel.kt (ViewModel)

// VM - ViewModel: 持有数据和业务逻辑,通过 LiveData 通知 View
class LoginViewModel : ViewModel() {

    private val model = UserModel
    
    // UI 状态的 LiveData
    private val _loginState = MutableLiveData<LoginUiState>()
    val loginState: LiveData<LoginUiState> = _loginState

    fun login(username: String) {
        _loginState.value = LoginUiState.Loading

        // 使用 ViewModelScope 协程来处理异步操作
        viewModelScope.launch(Dispatchers.IO) {
            model.login(username) { result ->
                val newState = result.fold(
                    onSuccess = { user -> LoginUiState.Success("欢迎, ${user.name}!") },
                    onFailure = { error -> LoginUiState.Error(error.message ?: "登录失败") }
                )
                // 切换回主线程更新 LiveData
                withContext(Dispatchers.Main) {
                    _loginState.value = newState
                }
            }
        }
    }
}

// 定义 UI 状态的密封类
sealed class LoginUiState {
    object Loading : LoginUiState()
    data class Success(val message: String) : LoginUiState()
    data class Error(val message: String) : LoginUiState()
}

LoginActivity.kt (View)

// V - View: 观察 ViewModel 中的数据变化来更新 UI
class LoginActivity : AppCompatActivity() {

    // 通过 ktx 库轻松获取 ViewModel
    private val viewModel: LoginViewModel by viewModels()
    // ... UI 控件声明 ...

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_login)
        // ... findViewById ...

        loginButton.setOnClickListener {
            viewModel.login(usernameEditText.text.toString())
        }
        
        // 观察 LiveData 的变化
        viewModel.loginState.observe(this) { state ->
            when (state) {
                is LoginUiState.Loading -> showLoading()
                is LoginUiState.Success -> {
                    hideLoading()
                    showSuccess(state.message)
                }
                is LoginUiState.Error -> {
                    hideLoading()
                    showError(state.message)
                }
            }
        }
    }
    
    // ... UI 更新方法 ...
}

别忘了在 build.gradle 文件中添加 ViewModel 和 LiveData 的依赖。

MVI (Model-View-Intent)

MVI 是一种更现代的架构模式,深受函数式编程思想的影响,它强调单向数据流状态的唯一可信源

  • Model: 在 MVI 中,Model 通常指UI 状态 (State)。它是一个不可变的数据结构,代表了 UI 在某一时刻的所有状态。
  • View: 负责渲染 UI 状态,并捕获用户意图 (Intent),将其发送出去。
  • Intent: 不要和安卓的 Intent 组件混淆。这里的 Intent 指的是用户的操作意图,例如 LoginClickedIntentUsernameChangedIntent 等。
  1. 环形单向数据流:
    • View 发送 Intent (用户意图)。
    • ViewModel (或类似角色) 接收 Intent,处理业务逻辑。
    • ViewModel 生成一个新的 State (UI 状态)。
    • View 订阅 State 的变化,并用新状态渲染自己。
  2. 唯一数据源: UI 的所有状态都由一个 State 对象管理,任何对 UI 的更新都必须通过生成一个新的 State 来实现。这使得状态变化变得可预测和易于调试。这一定程度上解决了 MVVM 架构的多数据来源导致UI变化难以追踪的问题。

代码实现

LoginContract.kt (State, Intent, Effect)

// M - State: UI 的状态,必须是不可变的
data class LoginViewState(
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

// I - Intent: 用户的意图
sealed class LoginIntent {
    data class LoginClicked(val username: String) : LoginIntent()
}

// Side Effect: 一次性事件,如 Toast 或导航
sealed class LoginEffect {
    data class ShowSuccessToast(val message: String) : LoginEffect()
}

LoginViewModel.kt (处理 Intent,生成 State)

// ViewModel: 处理 Intent,更新 State,发送 Effect
class LoginViewModel : ViewModel() {

    private val model = UserModel
    
    private val _state = MutableStateFlow(LoginViewState())
    val state: StateFlow<LoginViewState> = _state.asStateFlow()

    private val _effect = MutableSharedFlow<LoginEffect>()
    val effect: SharedFlow<LoginEffect> = _effect.asSharedFlow()

    // 统一处理所有 Intent
    fun processIntent(intent: LoginIntent) {
        when (intent) {
            is LoginIntent.LoginClicked -> login(intent.username)
        }
    }

    private fun login(username: String) {
        viewModelScope.launch {
            _state.value = _state.value.copy(isLoading = true, errorMessage = null)
            
            // 使用 Coroutine + Flow
            kotlin.runCatching {
                // 模拟异步调用
                withContext(Dispatchers.IO) { model.performLogin(username) }
            }.onSuccess { user ->
                _state.value = _state.value.copy(isLoading = false)
                _effect.emit(LoginEffect.ShowSuccessToast("欢迎, ${user.name}!"))
            }.onFailure { error ->
                _state.value = _state.value.copy(isLoading = false, errorMessage = error.message)
            }
        }
    }
}
// 可以在 Model 中提供一个挂起函数
suspend fun UserModel.performLogin(username: String): User {
    delay(1000)
    if (username == "admin") return User("Administrator")
    else throw Exception("用户名或密码错误")
}

LoginActivity.kt (View)

// V - View: 发送 Intent,订阅 State 和 Effect
class LoginActivity : AppCompatActivity() {
    
    private val viewModel: LoginViewModel by viewModels()
    // ... UI 控件 ...

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // ... setContentView, findViewById ...

        loginButton.setOnClickListener {
            viewModel.processIntent(LoginIntent.LoginClicked(usernameEditText.text.toString()))
        }
        
        // 订阅 State 变化来更新持久性 UI
        lifecycleScope.launch {
            viewModel.state.collect { state ->
                progressBar.visibility = if (state.isLoading) View.VISIBLE else View.GONE
                state.errorMessage?.let { Toast.makeText(this@LoginActivity, it, Toast.LENGTH_SHORT).show() }
            }
        }

        // 订阅 Effect 来处理一次性事件
        lifecycleScope.launch {
            viewModel.effect.collect { effect ->
                when (effect) {
                    is LoginEffect.ShowSuccessToast -> {
                        Toast.makeText(this@LoginActivity, effect.message, Toast.LENGTH_SHORT).show()
                    }
                }
            }
        }
    }
}

从 MVC 到 MVI,我们可以清晰地看到一个趋势:职责分离越来越明确,耦合度越来越低,代码的可测试性和可维护性越来越强。特别是从 MVVM 到 MVI,我们开始更多地借鉴函数式编程的思想,通过管理不可变的状态和单向数据流来构建更加稳健和可预测的应用。

Clean Architecture

相比于 MVC、MVP、MVVM 这些主要关注于 UI层 的架构模式外,Clean Architecture(整洁架构)是一个更高层次、更宏观的架构思想。

简单来说,如果把 MVP/MVVM 看作是如何组织一个具体页面的代码(View、Presenter/ViewModel、Model),那么 Clean Architecture 就是如何组织整个 App 所有模块代码的宏伟蓝图

Clean Architecture 由 Robert C. Martin (人称 “Uncle Bob”) 提出,其核心目标是 “分离关注点” (Separation of Concerns),通过将软件系统划分成不同的层次,来创建一个易于维护、独立于框架、可测试性极强的系统。

核心思想:依赖关系原则 (The Dependency Rule)

这是 Clean Architecture 最最核心的一条规则:

源代码的依赖关系,只能从外层指向内层。

想象一个洋葱或一组同心圆,内层代码对任何外层代码都一无所知。

这些层次通常代表什么呢?从内到外:

Entities (实体层)

这是最核心的内层。 定义了整个应用的核心业务对象和规则。在安卓中,这通常是你的数据模型类(例如 User, Product, Order 等),它们是纯粹的 Kotlin/Java 对象 (POJO/POKO),不应该包含任何与安卓框架、数据库或网络相关的代码。极度稳定,变动最少。它不知道任何其他层的存在。

Use Cases / Interactors (用例层 / 交互器)

这是应用的业务逻辑层。封装并实现了应用的所有业务用例。例如 LoginUseCase (处理登录逻辑)、GetUserProfileUseCase (获取用户信息)、PlaceOrderUseCase (下单逻辑) 等。它们会协调 Entities 来完成具体的业务操作。这一层同样是纯 Kotlin/Java 代码,不依赖任何外层。它知道 Entities,但不知道谁会来调用它,也不知道数据从哪里来(是从网络还是数据库)。

Interface Adapters (接口适配器层)

这是数据转换层。负责将 Use Cases 和 Entities 层的数据,转换成适合外层(如UI、数据库)使用的格式,反之亦然。你熟悉的 MVP 中的 Presenter 和 MVVM 中的 ViewModel 就生活在这一层。此外,还包括数据库的数据映射器 (Mappers)、网络请求返回的数据模型 (DTOs) 等。

它的作用就像一个双向翻译官。例如,ViewModel 调用 Use Case,获取到纯业务数据 (Entity),然后 ViewModel 将这个 Entity 转换成 UI 可以直接显示的格式 (UI Model)。

Frameworks & Drivers (框架与驱动层)

这是最外层,也是最不稳定的一层。包含所有具体的实现细节。例如:

  • UI: Activities, Fragments, Jetpack Compose UI
  • 数据库: Room, Realm 的具体实现
  • 网络: Retrofit, Volley 的具体实现
  • 安卓框架: 各种 Android SDK 的调用

这一层是所有东西粘合在一起的地方,依赖关系都指向内部。比如,Activity 持有 ViewModel 的引用,但 ViewModel 对 Activity 一无所知。

Clean Architecture 与 MVP/MVVM 的关系

很多人会误解 Clean Architecture 是 MVP/MVVM 的替代品,其实不是。它们是互补关系:

  • Clean Architecture 是一个宏观的、全局的架构设计。 它定义了整个应用的模块划分和依赖方向,比如把业务逻辑 (domain 模块) 和数据获取 (data 模块) 以及界面展示 (presentation 模块) 分开。
  • MVP/MVVM 是一个微观的、专注于 UI 层的设计模式。 它通常被应用在 Clean Architecture 的最外两层(Interface Adapters 和 Frameworks & Drivers)来组织 UI 代码。

可以这样理解一个典型的请求流程:

  1. View (Activity/Fragment) 在最外层,它接收到用户操作。
  2. View 通知 ViewModel (位于接口适配器层)。
  3. ViewModel 调用相应的 Use Case (位于用例层) 来执行业务逻辑。
  4. Use Case 可能会通过一个 Repository 接口 (接口定义在 Use Case 层) 来请求数据。
  5. 这个接口的具体实现 RepositoryImpl (位于框架驱动层或接口适配器层) 会决定是从网络 (Retrofit) 还是从本地数据库 (Room) 获取数据。
  6. 数据从内层一步步返回,经过适配器层的转换,最终由 ViewModel 提供给 View 进行展示。

在这个流程中,Use Case 层根本不关心数据是来自网络还是数据库,也不关心数据最终是显示在 Activity 上还是一个 Compose 屏幕上。这就实现了彻底的解耦。

优点总结

  1. 独立于框架 (Framework Independent): 核心业务逻辑不依赖于安卓 SDK,可以轻松迁移到其他平台(如桌面应用)。
  2. 可测试性强 (Testable): 内层的业务逻辑 (Use Cases, Entities) 是纯粹的 Kotlin/Java 代码,可以进行非常快速的单元测试,无需启动模拟器。
  3. 独立于 UI (UI Independent): 你可以随意更换你的 UI 实现(比如从 XML 布局换成 Jetpack Compose),而无需改动任何业务逻辑。
  4. 独立于数据库 (Database Independent): 你可以从 Room 切换到其他数据库,只需要改动最外层的具体实现,核心逻辑不受影响。
  5. 代码结构清晰 (Maintainable): 尤其在大型复杂项目中,严格的分层让代码职责分明,新人更容易上手,代码也更容易维护。

【Android进阶】Android集成Unity的两种方式

【Android进阶】Android集成Unity的两种方式

本文介绍了Android平台两种Unity交互的通信架构和集成方式

​Android平台常见动效

现在市面上的形形色色Android客户端,为了更优的用户体验,我们开发的上游产品和交互往往会在界面里设计很多动效。传统的一页页的静态展示页面已经不足以满足用户的审美需求了。

而动效的分类也是花样百出的,以播放时机来说有点击触发,打开页面触发,还有可跟随手指的交互持续触发的等等。有时候一些和数据耦合性较大的动效甚至需要我们自己来手写复杂的自定义View,比如曲线图、图表类型。

而我日常碰到的大部分的动效需求,还是依赖UI设计的同时来制作提供的,像那些短时间单次的展示类动效,往往实现方式比较随意,对资源的格式要求也不太严苛。

简单动画

  • 帧动画,在Android中,帧动画是通过Drawable动画实现的。你可以创建一个AnimationDrawable对象,然后在XML中定义一系列的帧(frames),每帧可以是一个Drawable资源。然后在代码中启动这个动画。注意确保你的每个Drawable资源的尺寸是一致的,以便在动画过程中保持帧的正确显示。
  • PAG动画,pag相较于上面的帧动画对性能更加友好。PAG是腾讯公司自主研发的一套完整动画工作流解决方案。最初的原因是为了解决更为复杂的视频编辑场景下动画渲染问题,同时又覆盖了UI动画和直播场景,于2022年1月在Github开源。其使用方法可以说相当简单,只需要先从github主页确定版本,到gradle里引入依赖,然后在我们应用的xml布局中放置pagView,没有额外的属性需要配置。最后在代码里设置其文件源,循环方式,调用播放即可。
  • MP4动画,比较直接,将动画导出为视频格式,直接获取mediaplayer实例,绑定surfaceView或者TextureView,再填文件,播放视频即可。需要关注的是surfaceView播放视频一开始可能会有黑屏问题,可以用静态图占位。

可交互3D动效

Kanzi动效

跟手可互动的动效,也不得不谈kanzi动效。以下介绍来自百科与官网:

Kanzi产品是行业领先的3D引擎和UI开发工具,支持高效率沉浸式3D效果,跨系统多屏互联并能与安卓生态完美融合,已经成为全球主流车厂智能座舱首选的UI开发工具和引擎。更新后的Kanzi架构可与安卓操作系统、生态系统深度兼容。Kanzi可基于安卓的任何功能提供强大的图形设计支持,确保高质量的图像效果。

对于Kanzi动效的集成使用方式,因为没有自己从头开始对接,我只按照顺序一笔带过,有不对的地方欢迎指正。首先我们集成kanzi运行所需的Runtime.aar,kanziJava支持库aar,资源文件,资源列表的txt等等,还需要在gradle里写明不可压缩的文件类型,以防止无法加载资源。

在使用上,我们先在XML布局中声明,同时通过属性填入asstes里的资源名,和资源文件绑定:

 <com.rightware.kanzi.KanziTextureView
        android:id="@+id/tx_KanziSurfaceView"
        android:layout_width="@dimen/dp_2560"
        android:layout_height="@dimen/dp_1190"
        app:clearColor="@android:color/transparent"
        app:kzbPathList="climate.kzb"
        app:layout_constraintTop_toTopOf="parent"
        app:name="climate"
        app:startupPrefabUrl="kzb://climate/StartupPrefab"
        tools:ignore="MissingConstraints" />

在Java代码里我们需要设置通信的工具类,在里面添加监听器来接收和上行下行信号的交互:

// 数据接口定义
public interface AndroidNotifyListener {
    void notifyDataChanged(String name, String value);

    void dataSourceFinish();
}

// 添加数据接收监听和下行通信
AndroidUtils.setListeners(this);
AndroidUtils.removeListeners(this);
AndroidUtils.setValue(SourceData.RightMidMove_up2down, y);

Unity动效

本文重点,Unity的大名在游戏界可谓如雷贯耳,记得小时候玩的很多游戏的开屏界面即有一个大大的 Unity 字样和图标。

Unity是实时3D互动内容创作和运营平台。包括游戏开发、美术、建筑、汽车设计、影视在内的所有创作者,借助Unity将创意变成现实。Unity平台提供一整套完善的软件解决方案,可用于创作、运营和变现任何实时互动的2D和3D内容,支持平台包括手机、平板电脑、PC、游戏主机、增强现实和虚拟现实设备。Unity作为全球领先的 3D 引擎之一,团结引擎可以为 3D HMI提供全栈支持。即为从概念设计到量产部署的整个 HMI 工作流程提供创意咨询、性能调优、项目开发等解决方案,从而为车载信息娱乐系统和智能驾驶座舱打造令人惊叹的交互式体验。

其实在第一版我们项目集成的是上面的Kanzi方案,其性能表现较Unity要差一些。性能还是其次,发起替换的主要原因还是在项目推进的过程中,对方工程师对动效样式的优化达不到评测部门的要求,后来更新迭代就更换了Unity方案。

而本文的重点也是在于Unity3D动效的使用,案例为车载IVI系统空调app的风向调节,交互逻辑比上面举的例子更加复杂,需要实时跟手,在交互热区范围内需要不断变化动效形态,并完成双向通信,保证动效和车载信号的一致性。

Android应用对接Unity集成的两种方案

以下提到的集成方案均可以在Unity的官方网站进行更加详细的查阅:

团结引擎 手册

通信协议制定

集成的第一步,要提前根据APP产品交互逻辑,来指定和Unity之间的通信协议。有哪些功能是开关,需要调整哪些属性。例如空调app里就涉及几个出风口的打开关闭,可以以0/1来区分。还有风口的方向调节,需要互传x,y坐标值。Android和Unity之间一般是采用JSON字符串来通信的。

而且,两方通信链路和Unity的集成方式还有关,像下面要谈到的第一种进程隔离方案,就是通过集成全量的Unity依赖包,利用aar内部JNI接口来通信的,而第二种Client/Server架构就是通过Android的AIDL接口来和单独的Unity服务端进程通信的。

进程隔离方案-UAAL(Render As Library)

基于UAAL(Render As Library),支持把渲染服务嵌入原生安卓APP。

  • Tuanjie 引擎可作为 Render Service,嵌入原生 Android APP,为原生 Android APP 提供 3D 内容
  • 支持多个 view,支持非全屏渲染,每个 APP 仅需集成 View 组件,脱离 Activity
  • 支持加载多个 Tuanjie 实例

Tuanjie Editor 打包出的 Android Studio 工程或 APK 包括 Client 和 Service 两部分。

UAAL 方案的优势在于,Unity 渲染服务和原生 APP 之间的通信链路是独立的,原生 APP 可以通过 JNI 接口和 Unity 渲染服务进行通信,而 Unity 渲染服务也可以通过 JNI 接口和原生 APP 进行通信。

这种方式集成的话,Unity会将渲染引擎,资源文件,和Android上层的通信代码都打包导出到一个aar中,其体积随动效的复杂程度而变化,同时会使集成方的apk包体积增加。而且项目里有多少方要使用Unity动效,就需要多少份的渲染引擎。这个方案由客户端来负责Unity控件的创建销毁,显示隐藏,一般适用一对一,通信链路简单的,即项目中可能只有一个模块需要使用Unity动效的情况。在多模块需要使用Unity的情况下,进程隔离的方案对性能的占用也比较高。

上层使用到的控件——UnityPlayer,它是一个Unity自定义的FrameLayout,里面有他们自己实现的一系列添加view,显示,和渲染逻辑。资源文件均存在于Unity打的依赖包中,对外不开放。

集成步骤

第一步,将Unity提供的aar放置于libs文件夹中,并在gradle里添加其编译引用。

implementation files('libs/UnityAnimation_0321V4.aar')

第二步,gradle中配置Unity所需的NDK版本,配置abifilters,设置要将哪些架构的动态库打包到apk中,对于车机项目来说只需要固定的某一种架构即可。还有设置不压缩的文件类型,使Unity可以顺利找到资源使用。

ndkVersion "23.1.7779620"

aaptOptions {
    noCompress = ['.tj3d', '.ress', '.resource', '.obb', '.bundle', '.tuanjieexp', 'global-metadata.so'] + tuanjieStreamingAssets.tokenize(', ')
    ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~"
}

ndk {
    abiFilters 'arm64-v8a'
}

有一点需要注意,我们还需要在项目的string.xml资源文件中添加Unity所需的一条 String 资源,否则Unity侧会空指针。

<string name="game_view_content_description">Game view</string>

第三步,将要显示Unity动效的页面 Activity 改为继承自 UnityPlayerActivity ,Unity的核心显示控件是 UnityPlayer ,它的创建销毁,显示隐藏,由 UnityPlayerActivity 来统一管理,项目中集成这个 Activity 的子类再将 mUnityPlayer 通过 addView() 添加到自己的根布局 ViewGroup 中当背景即可,而且可以在xml上面继续增加其他View控件。

第四步,封装Unity通信工具类,Android给Unity发消息可以直接通过 UnityPlayersendMessage() 静态方法,传入Unity通信协议中指定的类名。

UnityPlayer.UnitySendMessage(OBJ_NAME, METHOD_NAME, communicateMessage)

Unity使用C#开发,其给 Android 上层发消息则是通过反射回调信号类里的方法实现的,所以我们最好将信号管理类做成单例的,并给其Unity留下一个方法或者成员,可以拿到我们类的实例,顺利反射回调。我这里使用的是一个Kotlin类声明,并对外暴露一个公开的 unityInstance 成员。而这个方法 onReceiveMsgFromUnity ,即是Unity的反射调用,我们在其中进行信号的解析,并传到View中去,注意这个方法不是在主线程中反射的,所以后面需要优化一波。

object UnityMessageHelper {

    val unityInstance = this

    // Unity给Android的消息回调
    fun onReceiveMsgFromUnity(msg: String) {
        LogUtils.d(TAG, "onReceiveMsgFromUnity: $msg")
        if (listenerList.size > 0) {
            listenerList.forEach {
                it.onReceiveUnityMessage(msg)
            }
        }
    }
}

信号类UnityMessageHelper的优化

由于我们的目标工程是空调app,在用户调节风向时的回调频率相当高,而自动扫风模式下,底层上传的数据频率也相当高,所以不适合到主线程中操作这么多的数据,我们用协程,配合Default调度器来处理这种CPU密集型的任务。两条链路,用户手指的拖动操作时,Unity反射回调的线程本身都是工作线程了,所以我们在使用自定义的接口回调到View类的时候,使用MainScope.launch包一层,确保是到主线程更新我们的UI。而自动扫风模式从域控制器接收到风口点击的坐标值时,我们拿到数据后给Unity下发信号,更新动效的指向位置。可以使用协程上下文切换,withContext(Dispatcher.Default)将其切到工作线程里发送给Unity。

遇到的问题

Unity方给的aar里的基类Activity适用与绝大多数的普通应用,但是我这里空调app的定位是一个高层级的悬浮窗,我的工程里压根就没有Activity。

这个时候我们用不上他们定义的 UnityPlayerActivity ,只能使用原生Raw的UnityPlayer,自己管理其创建,销毁,resume和pause。这里需要注意的是,UnityPlayer的创建需要传一个Context上下文,而应用里又没有Activity类型的Context,故只能使用非Activity类型的Context,在实践中发现,这个UnityPlayer的实例必须是我们的应用拿到可用的窗口 token 句柄之后,才能被成功创建,否则就会报错。

所以正确的创建与初始化顺序是先使用WindowManager添加一个xml布局inflate来的ViewGroup,在其onAttachToWindow的方法回调之后,再创建UnityPlayer的实例,并添加到这个ViewGroup的布局中去,调用其resume方法。

这样添加的UnityPlayer有一个无法解决的黑屏问题,因为Unity的渲染加载至少都需要4,5秒,期间我们只能在更上层的View里设置静态背景图覆盖上去,等Unity加载完毕,发送ready的回调之后,我们移除掉这个占位的静态图,展示Unity动效的界面。这也是进程隔离的方案的一个很棘手的问题。我的解决方案是在开机的时候往屏幕外添加一个View专门来初始化加载Unity,加载完毕后,再将UnityPlayer给从里面remove掉,重新添加到实际的要展示的窗口中去,这样打开界面的时候可以略去加载的耗时,稍微减少页面僵直的时间。

单进程-URAS(Render As Service)

一个渲染服务 Service 支持多个 Client APP 运行,且每个 Client APP 相互独立、互不干扰、可自更新。

  • 仅需一个 Service,多个工程共用同一个 Service,每个工程均正常运行且互不干扰
  • 一个新 Client 集成到已运行 Service 中,新 Client 可正常运行和渲染,已运行的 Client 和 Service 均不受干扰。
  • 已运行的 Service 和 Client 中,关闭一个 Client,不影响其他 Client 和 Service 的正常运行
  • 每个 Client 可通过 OTA 单独更新,更新后可正常运行,且不影响其他 Client 和 Service

Unity Rendering as Service(简称URAS) 的渲染方案是团结引擎特有的,无需在多个安卓应用中集成多个Unity 3D player,而是后台运行,前端应用可直接调用,节省系统资源,更适合多应用动效一镜到底的设计。

相较进程隔离方案的优势

这个方案是在UAAL方案的基础上升级的,所以有一些前期工作是重复的,不作重复的阐述。

它是将要显示的几个Unity引擎都打包到同一个Server服务端去统一管控。其实服务端的apk打包也是拿到Unity提供的服务端AAR打进一个空工程,内部逻辑也隐藏到了AAR中。服务端和客户端的通信采用我们熟知的AIDL接口来实现。而且这个服务端我们需要设置为persistent应用,使其能开机自启,自动执行渲染等工作,其他应用有显示需求可以秒开,并且长时间不显示也不会自己回收资源了,客户端的黑屏问题也可以解决了。

相比于UAAL方案,客户端需要集成的是一个体积很小的Client.aar,对于客户端apk的体积控制是有优势的。

URAS渲染原理

BufferQueue

BufferQueue 机制 是 Android 图形系统中最核心、最重要的底层机制之一,它实现了图形数据的生产者(Producer)和消费者(Consumer)之间的高效、零拷贝的通信。

它可以将一个组件(生产者)生成的图形数据缓冲区,安全、高效地传递给另一个组件(消费者)以供显示或进一步处理。 关键点是零拷贝,数据在生产者和消费者之间是通过内存句柄 (Handle) 传递的,而不是通过 CPU 复制实际的像素数据。这对于高性能的图形(如视频、3D)渲染至关重要。

BufferQueue 本质上是一个队列,但它管理的不是数据本身,而是图形缓冲区 (Graphic Buffers) 的句柄。

  • 生产者调用 dequeueBuffer() 从队列中获取一个空闲的缓冲区。将图形数据(例如一帧画面)绘制到这个缓冲区中。调用 queueBuffer():将填充好的缓冲区排入队列,通知消费者数据已准备好。
  • 消费者是需要使用图形数据进行显示的组件,调用 acquireBuffer(),从队列中获取一个生产者刚刚填充好的缓冲区。使用缓冲区中的数据进行合成、显示或进一步处理。调用 releaseBuffer() 将缓冲区释放回队列,使其再次变为空闲,供生产者重复使用。
BufferQueue 的工作流程
  1. 初始化: 生产者和消费者通过 Binder IPC 建立连接,并协商 BufferQueue 的参数(例如最大缓冲区数量)。BufferQueue 会根据需求,通过 Gralloc 内存分配器分配实际的图形内存(通常在 GPU 可访问的共享内存中)。
  2. 生产者获取缓冲区: 生产者调用 dequeueBuffer(),从 BufferQueue 中拿到一个空闲的缓冲区句柄(ID = n)。
  3. 生产者渲染: 生产者(通常是 GPU)将一帧画面渲染到缓冲区 n 中。
  4. 生产者入队: 生产者调用 queueBuffer(),将缓冲区 n 放入待消费队列。
  5. 消费者通知: BufferQueue 通知消费者(例如 SurfaceFlinger),新数据已到达。
  6. 消费者获取缓冲区: 消费者调用 acquireBuffer(),从队列中取出缓冲区 n 的句柄。
  7. 消费者处理/显示: 消费者(例如 SurfaceFlinger)使用缓冲区 n 的数据进行合成,最终交给硬件显示。
  8. 消费者释放: 消费者完成对缓冲区 n 的使用后,调用 releaseBuffer(),将缓冲区 n 返回给空闲列表。
  9. 循环: 缓冲区 n 再次变为“空闲”,生产者可以再次获取并重复使用。
跨进程渲染

使用 Surface / SurfaceTexture / SurfaceView,这是Android中实现跨进程图形数据共享和渲染的最核心机制,尤其适用于高性能的3D渲染(如游戏、AR/VR、视频播放、复杂图形引擎等)。

Android的图形系统基于 BufferQueue 机制。一个 Surface 本质上就是 BufferQueue 的生产者(Producer)端。当进程B将渲染好的图形数据(例如,一个OpenGL ES的帧)放入 BufferQueue 后,进程A的 SurfaceViewTextureView(它们是 BufferQueue 的消费者 Consumer)就可以从队列中取出并直接显示。

当客户端的 SurfaceView 创建时,会向系统请求一个 Surface 对象,这个 Surface 就关联了一个 BufferQueue。通过 Binder IPCSurface 对象的句柄(或一个包装了 Surface 的特殊对象)传递给Unity服务端。

服务端在接收到 Surface 句柄后,将其作为 渲染目标(例如,作为 OpenGL ES 的 EGL 窗口)。将图形数据(如 glSwapBuffers())渲染到这个 Surface 关联的 BufferQueue 中。一旦服务端将渲染数据提交到 BufferQueue,系统会自动将这些数据交给客户端的 SurfaceView 进行合成和显示,绕过了传统的Android View绘制流程(即 onDraw)。

这种渲染方式性能极高,因为数据传输是在底层图形缓冲区级别完成,无需CPU进行像素拷贝,适用于视频流、3D渲染等对帧率要求高的场景。

也可以使用 TextureView + SurfaceTexture + Binder IPC,这是一种特殊的组合,TextureView 将内容渲染到一个 SurfaceTexture 上,而 SurfaceTexture 也可以跨进程共享,通常用于更灵活的纹理操作(例如对渲染内容进行旋转、缩放等 View 级别的变换)。它的底层原理与 SurfaceView 类似,也是基于 BufferQueue

URAS集成与使用方式

我们只需要在 gradle 里引入这个客户端aar。在gradle sync之后,将远程的 UnityView 添加到自己的布局中去,配置好display参数(用来给服务端区分是哪个引擎的内容),并指定服务端的包名。承载的View类型有 SurfaceViewTextureView 两种,而我的应用界面因为是一个悬浮窗口,设计有进出场的渐隐渐出动效,而 SurfaceView 不可以线性地设置alpha动画,所以选取 TextureView 来当作容器。

<com.unity3d.renderservice.client.TuanjieView
    android:id="@+id/unityview"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:tuanjieDisplay="2"
    app:tuanjieServicePkgName="com.tuanjie.renderservice"
    app:tuanjieViewType="TextureView" />

剩余的代码逻辑仅仅是服务端 Service 的启动,添加服务连接的回调,消息回调。由于服务端为若干个 Client 的公共引擎,所以连 resumepause 都不需要处理,因为这两个操作会对所有的客户端都生效。我们只需要确保启动服务,并使用正确的display即可,面板退到后台可以使用 setVisbility 来控制其显示隐藏。

除此之外,我们的通信工具类, UnityMessageHelper 还需要实现两个接口,一个服务连接状态接口,一个业务数据的消息回调接口,代码如下:

object UnityMessageHelper : TuanjieRenderService.Callback, SendMessageCallback {
    override fun onServiceConnected() {
        LogUtils.w(TAG, "onUnityRenderServiceConnected")
    }
   
    override fun onServiceDisconnected() {
        LogUtils.w(TAG, "onUnityRenderServiceDisConnected")
        ...
    }
   
    override fun onServiceStartRenderView(p0: Int) {
        LogUtils.i(TAG, "onServiceStartRenderView")
    }

    override fun onClientRecvMessage(message: String?) = null

    // 服务端的消息回调
    override fun onClientRecvMessageWithNoRet(msg: String?) {
        // 回调消息的解析
    }
}

可以说URAS方案由于其统一管控,一对多的特点,在性能和客户端的易集成性方面,是优于UAAL方案的。另外,还可以从架构层面上,联动更多的动效使用模块,实现一镜到底的丝滑转场。

【Android进阶】Handler消息机制的上下层设计与流程详解

【Android进阶】Handler消息机制的上下层设计与流程详解

本文介绍了Android中特有的Handler消息处理机制

Android UI 更新是线程不安全的,也就是说,你不能直接在子线程中操作 UI。所有 UI 操作都必须在主线程(或称 UI 线程) 中进行,Activity的所有生命周期回调和UI更新操作都发生在应用程序的主线程中。

这就引出了一个问题:如果后台线程完成了数据加载或计算,怎么才能通知主线程更新 UI 呢?答案就是 Handler 消息机制。

简单来说, Handler 消息机制 是一套允许你将任务(消息或可运行对象)发送到另一个线程的消息队列中,并在该线程中执行这些任务的框架。

使用场景

UI 线程更新

安卓规定,所有对 UI 的操作都必须在主线程(UI 线程)中进行。当你在子线程中执行耗时操作(如网络请求、数据库查询)并需要更新 UI 时,就不能直接在子线程操作 UI 元素。这时,Handler 就派上了用场。你可以在子线程中发送一个 MessageRunnable 到主线程的 Handler,然后 Handler 会在主线程中处理这个消息或运行这个 Runnable,从而安全地更新 UI。

线程间通信

除了 UI 更新,Handler 机制也是实现线程间通用通信的重要方式。例如两个子线程之间的消息传递,还有Service与其他组件的通信,或者单个组件内部的消息处理。

延迟任务和定时任务

Handler 提供了 postDelayed()sendMessageDelayed() 等方法,可以让你在指定的时间后执行某个任务或发送某个消息。例如,等待某个界面加载完成后再开始播放动画。每隔一段时间从服务器获取最新数据。应用发送验证码后的倒计时。还可以用于防抖过滤,在短时间内多次点击某个按钮时,可以使用 Handler 延迟处理点击事件,只响应第一次点击。

异步任务的封装

许多安卓框架和库在底层也利用了 Handler 机制来处理异步任务,例如AsyncTask,虽然已被官方标记为 deprecated,但其内部实现就包含了 Handler 来处理子线程与主线程的通信。

其他特定场景

当用户在 EditText 中输入时,系统会通过 Handler 将输入事件传递给对应的 View。另外,部分动画框架会使用 Handler 来调度动画的每一帧。还有安卓系统内部的很多事件(如触摸事件、生命周期事件)的分发都可能间接涉及到 Handler 机制。

可以看出,Handler最主要的任务就是实现线程间的消息传递和处理。这其中的核心需求,就是就是如何 高效地等待消息

在 Android 应用中,主线程(UI 线程)负责处理用户界面更新和所有用户输入事件。一旦系统检测到主线程发生了长时间的阻塞,就会弹出ANR(Application Not Responding)弹窗并终结应用。为了避免ANR, 主线程不能被耗时的操作阻塞

Looper 与 Handler 协同工作,为每个线程维护一个消息队列。Looper 会不断检查这个队列是否有新消息需要处理。

这里的关键挑战在于:Looper 如何 在没有消息时高效地等待 ,而不是持续消耗 CPU 资源进行busy-waiting。传统的 忙等待 会极大地浪费电量并降低系统性能。因此,需要一种能够让线程在无事可做时进入睡眠状态,并在有新事件发生时被唤醒的机制。

Linux体系和文件描述符简介

Linux系统中,应用程序到系统内核的体系架构:

Linux 操作系统的体系架构分为用户态和内核态(用户空间和内核空间),内核本质上可以说属于一种桥接软件,往下控制着计算机的硬件资源,往上又给上层应用程序提供运行的环境。

而平常所说的 用户态 就是上层应用程序的活动空间,应用程序的执行又依托于内核提供的资源,包括 CPU 资源、存储资源、I/O 资源 等,为了让上层应用能够访问这些资源,内核必须为上层应用提供访问的接口,也就是 系统调用(system call)

系统调用 是受控的内核入口,借助这一机制,进程可以请求内核以自己的名义去执行某些动作,以 API 的形式,内核提供有一系列服务供程序访问,包括创建进程、执行 I/O 以及为进程间通信创建管道等。

文件描述符

Linux 继承了 UNIX 一切皆文件 的思想,在 Linux 中,所有执行 I/O 操作的系统调用都以文件描述符指代已打开的文件,包括管道(pipe)、FIFO、Socket、终端、设备和普通文件等

文件描述符(File Descriptor) 是 Linux 中的一个索引值,系统在运行时有大量的文件操作,内核为了高效管理已被打开的文件会 创建索引 ,用于 指向被打开的文件 ,这个索引就是文件描述符。

文件描述符往往是数值很小的非负整数,获取文件描述符一般是通过系统调用 open() 韩素, 在参数中指定 I/O 操作目标文件的路径名

事件文件描述符enevtfd

eventfd 可以用于线程或父子进程间通信,内核通过 eventfd 也可以向用户空间发送消息,其核心实现是 在内核空间维护一个计数器 ,向用户空间暴露一个与之关联的匿名文件描述符,不同线程通过 读写该文件描述符通知或等待对方 ,内核则通过写该文件描述符通知用户程序。

在 Linux 中,很多程序都是事件驱动的,也就是通过 select/poll/epoll 等系统调用在一组文件描述符上进行监听,当文件描述符的状态发生变化时,应用程序就调用对应的事件处理函数,有的时候需要的 只是一个事件通知 ,没有对应具体的实体,这时就可以使用 eventfd

管道(pipe) 相比,管道是 半双工 的传统 IPC 方式,两个线程就需要两个 pipe 文件,而 eventfd 通信只需要打开一个文件。文件描述符又是非常宝贵的资源,Linux 默认也只有 1024 个。

eventfd 非常节省内存,可以说就是一个计数器,是 自旋锁 + 唤醒队列 来实现的,而管道一来一回在用户空间有多达 4 次的复制,内核还要为每个 pipe 至少分配 4K 的虚拟内存页,就算传输的数据长度为 0 也一样。这就是为什么只需要通知机制的时候优先考虑使用eventfd

插入 自旋锁 概念

自旋锁(Spinlock)是计算机科学中用于多线程同步的一种锁机制。

当一个线程尝试获取一个已经被其他线程持有的自旋锁时,它不会立即被操作系统挂起(阻塞)并切换到其他任务。相反,它会进入一个循环(即“自旋”),反复地检查锁是否已经被释放。

常规的互斥锁(如Mutex)在锁被占用的情况下,会使请求锁的线程进入休眠状态,这涉及上下文切换(Context Switch):

  • 将当前线程的状态保存起来。
  • 将CPU调度给另一个准备运行的线程。

上下文切换是需要消耗CPU时间的。自旋锁通过“忙等待”的方式, 避免了这种上下文切换的开销

这里是用来保护 eventfd 内部的计数器,确保在多线程环境下对计数器的操作是原子的。即当Looper线程 B 正在读取计数器的值时,Looper线程 A 不能修改计数器的值,否则会导致计数器的值不一致。

eventfd具体工作流程

eventfd 它创建了一个内核维护的 64 位计数器,并将其关联到一个文件描述符。这个文件描述符可以像普通文件描述符一样进行读写操作,并支持 select()poll()epoll() 等 I/O 多路复用机制,从而实现事件的等待和通知。

使用 eventfd() 系统调用可以创建一个 eventfd 对象,并返回一个文件描述符。你可以指定一个初始值 initval 来初始化计数器。

#include <sys/eventfd.h>

int eventfd(unsigned int initval, int flags);
核心机制:读写计数器
  • 写入 (write):
    • 当你向 eventfd 对应的文件描述符写入一个 8 字节的 uint64_t 值时,这个值会被加到 eventfd 内部的计数器上。
    • 如果写入会导致计数器溢出(超过 uint64_t 的最大值),write() 操作会阻塞,直到有 read() 操作消耗了计数器的一部分值,或者如果文件描述符是非阻塞模式,则会立即失败并返回 EAGAIN
  • 读取 (read):
    • 当你从 eventfd 对应的文件描述符读取一个 8 字节的 uint64_t 值时,会发生以下情况:
      • 非信号量模式(默认): read() 操作会返回当前计数器的值,并将计数器重置为 0。
      • 信号量模式(使用 EFD_SEMAPHORE 标志创建): read() 操作会返回 1,并将计数器减 1。
    • 如果计数器为 0,read() 操作会阻塞,直到有 write() 操作增加计数器的值,或者如果文件描述符是非阻塞模式,则会立即失败并返回 EAGAIN

eventfd 的强大之处在于 它与 Linux 的 I/O 多路复用机制(如 select()poll()epoll())的集成

  • eventfd 计数器 大于 0 时,它被认为是 可读的。这意味着你可以使用 select()poll()epoll() 监测这个文件描述符的读事件,当事件发生时(计数器非零),你的程序就会被唤醒。
  • eventfd 计数器 小于 0xfffffffffffffffe (即 uint64_t 的最大值 - 1) 时,它被认为是 可写的。这意味着你可以安全地向它写入一个 1 而不会导致溢出。

总的来说,eventfd 提供了一种简单、高效且灵活的事件通知机制,特别适用于需要通过文件描述符进行事件等待和信号传递的场景。

epoll机制

如何实现上述这种高效地等待呢? 需要先了解下Linux上的三种IO机制:select,poll和epoll

在Linux中,pollselectepoll 都是用于​​I/O多路复用(I/O Multiplexing)​​的机制。它们的主要作用是让一个进程/线程能够 同时监控多个文件描述符 (File Descriptors,FDs),比如套接字(sockets)、管道(pipes)等,以判断这些描述符是否可读、可写或发生异常,从而高效地处理多个I/O事件,而无需为每个FD创建单独的线程或进程。

这三种机制的核心目标都是:

  • ​避免阻塞​​:当一个进程需要同时监听多个I/O操作(如多个socket连接)时,不用为每个连接创建一个线程或进程,从而节省系统资源。
  • ​提高效率​​:通过一次系统调用,就可以检查多个FD的状态,而不是对每个FD逐一调用read/write等函数。

1. select

程序将自身所关注的 文件描述符(FD) 集合传给内核,内核检查这些FD是否有事件(可读、可写、异常),然后返回。由于FD集合大小有限(通常1024),且每次调用都需要重新传入FD集合,效率较低。​主要​缺点​​有三点,FD数量受限。每次调用都要传递整个FD集合,即使只有一个FD发生变化。内核使用线性扫描判断FD状态,效率低。

2. poll

​​工作原理​​与select类似,但使用pollfd结构体数组来表示FD集合,不再有1024的限制。可以支持更多的FD。​​缺点​​就是每次调用仍然需要传递整个FD集合。内核依然使用线性扫描,效率没有本质提升。

epoll:可扩展的 I/O 事件监控器

Android Native 层的 Looper 实现利用了 Linux 内核提供的 epoll 系统调用。epoll 是一种高级的 I/O 事件通知机制,允许一个线程高效地 监控多个文件描述符 ,以判断它们是否准备好进行 I/O 操作。

在 Looper 的上下文中,epoll_wait() 函数用于让线程进入睡眠状态,直到有事件发生。相较于早期机制如 selectpollepoll 具有显著的优势:

  • 可扩展性(Scalability)epoll 的性能随着被监控文件描述符数量的增加而保持良好。与 selectpoll 每次调用都需要内核遍历所有文件描述符列表不同,epoll 在内核中维护了一个 “兴趣列表” 。当事件发生时,内核直接提供一个已就绪的文件描述符的列表,避免了昂贵的线性扫描。这对于 Android 这样复杂的系统至关重要,因为一个线程可能需要等待各种不同的事件源。
  • 效率(Efficiency):通过将监控任务委托给内核,线程可以在不需要时保持低功耗的睡眠状态,直到真正需要被唤醒,从而节省电量并提高系统性能。
  • 触发模式(Trigger Modes)epoll 支持边沿触发(Edge-Triggered, ET)和水平触发(Level-Triggered, LT)两种模式,为事件处理提供了更精细的控制。水平触发通知就是文件描述符上可以非阻塞地执行 I/O 调用,这时就认为它已经就绪。边缘触发通知就是文件描述符自上次状态检查以来有了新的 I/O 活动,比如新的输入,这时就要触发通知。

epoll 机制最大的优化,其实就是 避免了对整个“兴趣列表”的轮询(遍历),转而依赖内核的主动通知来获取“就绪列表”。

eventfd:轻量级的唤醒信号

尽管 epoll 提供了高效的等待机制,但当另一个线程向目标线程的消息队列发送新消息时,需要一种方式来唤醒处于睡眠状态的 Looper 线程。这就是 eventfd 发挥作用的地方。

上文介绍过,eventfd被创建时,会维护一个64位的计数器,当线程向eventfd写入数据时,会将计数器加1,当线程从eventfd读取数据时,会将计数器减1。当这个值大于0,就说明是可读状态,当这个值小于溢出的最大值就认为其处于可写状态。

而在 Android Handler 框架中,eventfd 的工作原理如下:

  1. 创建:当一个 Looper 在线程上初始化时,它的底层原生实现会创建一个 eventfd 文件描述符。
  2. 监控:这个 eventfd 文件描述符随后被添加到 Looper 的 epoll 实例中,epoll_wait() 将会监控它。
  3. 唤醒:当另一个线程向目标线程的 MessageQueue 发布消息时,原生 MessageQueue 代码会向 eventfd 写入一个 1。这个写入操作会触发 eventfd,使其变为“可读”状态。
  4. 解除阻塞:阻塞 Looper 线程的 epoll_wait() 调用会立即返回,表明 eventfd 上有事件发生。
  5. 消息处理:Looper 随后从其队列中检索并处理新消息。

epoll实例及其核心api

epoll API 的核心数据结构称为 epoll 实例 ,它与一个 打开的文件描述符 关联,这个文件描述符不是用来做 I/O 操作的,而是内核数据结构的句柄,这些内核数据结构实现了记录兴趣列表和维护就绪列表两个目的。

那么这两个列表里面都是一些什么内容呢?

兴趣列表 (Interest List)

兴趣列表epoll 实例维护的、内核中的一个数据结构。它记录了 Looper 线程想要监听的所有文件描述符及其对应的事件类型

Looper 被创建时,或者添加新的监听事件源时,它会通过 epoll_ctl() 系统调用,将对应的文件描述符及其感兴趣的事件(例如读事件 EPOLLIN)添加到 epoll 实例的兴趣列表中。

在 Android Looper 的实现中,兴趣列表里主要包含两种类型的数据:

核心就是 用于唤醒 Looper 的 eventfd 事件文件描述符 。当其他线程(比如通过 Handler.sendMessage())向 Looper 的消息队列发送消息时,最终会在底层向这个 eventfd 写入数据。这个写入操作会触发 eventfd 上的可读事件。epoll 就会监测到这个事件,从而唤醒正在 epoll_wait() 的 Looper 线程。这是 Looper 从睡眠中醒来处理新消息的核心机制。

另外还可能有被监听的其他文件描述符 ,这些可以是其他 I/O 源的文件描述符,例如管道 (pipes),可能用于更复杂的 IPC 场景。 socket 文件描述符:如果 Looper 线程也负责网络通信。各种 Linux 特殊文件:如 inotify (文件系统事件通知)、timerfd (定时器事件) 等,如果应用有特殊需求,都可以将其文件描述符添加到 Looper 的 epoll 监听中。

因为 Looper 不仅仅处理 Java 层面的消息,它 在原生层也可以监听和处理各种系统事件。通过将这些文件描述符加入兴趣列表,Looper 能够在一个统一的事件循环中同时处理 Java 消息和原生系统事件。

每个被添加到兴趣列表的文件描述符,通常还会附带一个与之关联的 用户数据(user data)。在 epoll 中,这个用户数据通常是一个 epoll_data_t 联合体,它可以是一个指针或一个整数。在 Android Looper 中,它通常被用来指向一个内部结构体,该结构体包含了与这个文件描述符相关的回调函数或上下文信息,以便在事件发生时能够正确地对应处理。

就绪列表 (Ready List)

就绪列表 也是 epoll 实例维护在内核中的一个数据结构。它记录了当前已经发生事件、可以进行 I/O 操作的文件描述符

Looper 线程调用 epoll_wait() 时,如果兴趣列表中的任何文件描述符上发生了它所关注的事件,内核就会将这些“就绪”的文件描述符及其发生的事件类型填充到就绪列表中。epoll_wait() 随即返回,Looper 线程就可以遍历这个就绪列表,对每个就绪的描述符执行相应的处理逻辑。

就绪列表中的数据通常包含:

  1. 就绪的文件描述符 (File Descriptor): 即兴趣列表中发生了事件的那个文件描述符。
  2. 发生的事件类型 (Events): 描述该文件描述符上发生了什么类型的事件,例如 EPOLLIN(有数据可读)、EPOLLOUT(可以写入数据)、EPOLLERR(发生错误)等。
  3. 对应的用户数据 (User Data): 这是在将文件描述符添加到兴趣列表时一同传入的那个用户数据。Looper 会利用这个用户数据来识别事件源,并执行对应的回调函数来处理事件。例如,如果是 eventfd 就绪,它就知道有新消息需要处理;如果是其他文件描述符就绪,它就知道对应的原生 I/O 事件发生了。

总结就是兴趣列表让 Looper 可以声明它关注的所有事件源,而就绪列表则让 Looper 能够精确地知道哪些事件已经发生,从而避免了不必要的轮询,实现了响应式和低功耗的事件驱动模型。

epoll四个主要的api

epoll API 由以下 4 个系统调用组成。

epoll_create() 创建一个 epoll 实例,返回代表该实例的文件描述符,有一个 size 参数,该参数指定了我们想通过 epoll 实例检查的文件描述符个数。

epoll_creaet1() 的作用与 epoll_create() 一样,但是去掉了无用的 size 参数,因为 size 参数在 Linux 2.6.8 后就被忽略了,而 epoll_create1() 把 size 参数换成了 flag 标志,该参数目前只支持 EPOLL_CLOEXEC 一个标志。

epoll_ctl() 操作与 epoll 实例相关联的列表,通过 epoll_ctl() ,我们可以增加新的描述符到列表中,把已有的文件描述符从该列表中移除,以及修改代表文件描述符上事件类型的掩码。

epoll_wait() 用于获取 epoll 实例中处于就绪状态的文件描述符。

Handler完整链路

Android消息机制流程图:

消息机制初始化

消息机制初始化流程就是 Handler、Looper 和 MessageQueue 三者的初始化流程,Handler 的初始化流程比较简单.

当你直接在 ActivityonCreate() 或其他 UI 线程回调中创建一个 Handler 时,通常不需要显式地初始化 Looper,因为系统已经为你做好了。安卓应用的入口点是 ActivityThread ,当应用进程启动时,ActivityThread 会被创建。在 ActivityThread 的 main() 方法中,它会调用 Looper.prepareMainLooper()

Looper.prepareMainLooper()

这个静态方法是主线程 Looper 初始化的关键。它会检查当前线程是否已经有 Looper(避免重复创建)。

  • 如果当前线程没有 Looper ,则先​创建一个新的 MessageQueue ​。再​创建一个 Looper 对象​​,并将 MessageQueue 关联到 Looper 上。
  • Looper 存储到当前线程的 ThreadLocal 中(确保每个线程有自己的 Looper)。

当我们通过 Java 层的 Looper.prepare()Looper.prepareMainLooper() 方法初始化 Looper 时,它会触发 Native 层的对应操作。简而言之,Native层的初始化过程主要涉及以下几个关键步骤:

  1. 创建 Native MessageQueue 对象:这是消息的实际存储和管理容器。
  2. 创建 Native Looper 对象:它将与 MessageQueue 关联,并负责消息的调度和分发。
  3. 初始化 epoll 实例:这是 Looper 高效等待消息的关键。
  4. 初始化 eventfd :作为唤醒 Looper 的信号机制。

Native层的prepare方法:

// Native层 Looper.cpp (简化版)
void Looper::prepare() {
    // 1. 获取或创建 Looper 的 ThreadLocal 存储
    // Looper::TLS_KEY 是一个线程局部存储键,确保每个线程拥有独立的Looper实例
    // 如果当前线程已经有一个Looper,则会报错,因为一个线程只能有一个Looper。
    // Looper::gLooper 实际上是一个TLS (Thread Local Storage) 变量,
    // 它在每个线程中保存一个 Looper 指针。
    if (gLooper != nullptr) {
        // ... 抛出异常:一个线程只能prepare一次Looper
    }

    // 2. 创建 Native MessageQueue 对象
    // 这个MessageQueue对象是Handler消息的实际存储队列
    sp<MessageQueue> messageQueue = new MessageQueue();

    // 3. 创建 Native Looper 对象
    // Looper::create() 内部会完成 Looper 对象的核心创建和 epoll/eventfd 的初始化
    sp<Looper> looper = Looper::create(messageQueue);

    // 4. 将 Looper 对象存储到当前线程的 ThreadLocal
    // 这样,在当前线程中,任何Handler的构造函数都能通过Looper::getForThread()获取到它。
    gLooper = looper; 
}

通过Looper::create方法来创建Looper对象:

// Native层 Looper.cpp (简化版)
sp<Looper> Looper::create(sp<MessageQueue> messageQueue) {
    // 1. 创建 Looper 实例
    sp<Looper> looper = new Looper(messageQueue);

    // 2. 初始化 epoll 实例
    // looper->mEpollFd = epoll_create1(EPOLL_CLOEXEC);
    // epoll_create1() 返回一个 epoll 实例的文件描述符 (mEpollFd)。
    // EPOLL_CLOEXEC 标志确保这个文件描述符在执行 execve() 系统调用时会被关闭,防止子进程意外继承。
    looper->mEpollFd = epoll_create1(EPOLL_CLOEXEC); 
    if (looper->mEpollFd < 0) {
        // ... 错误处理
    }

    // 3. 初始化 eventfd
    // looper->mWakeEventFd = eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK);
    // eventfd() 创建一个 eventfd 文件描述符 (mWakeEventFd)。
    // 初始值为0。
    // EFD_CLOEXEC 同样确保 execve() 时关闭。
    // EFD_NONBLOCK 表示这是一个非阻塞的eventfd,写入操作不会阻塞。
    looper->mWakeEventFd = eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK); 
    if (looper->mWakeEventFd < 0) {
        // ... 错误处理
    }

    // 4. 将 eventfd 添加到 epoll 的兴趣列表
    // looper->addFd() 是一个内部方法,用于将文件描述符添加到 epoll 监听列表。
    // 它通过 epoll_ctl(EPOLL_CTL_ADD, ...) 实现。
    // 监听 EPOLLIN 事件,表示 eventfd 有数据可读(即被写入了)。
    // 第四个参数 (LOOPER_ID_WAKE) 是一个标识符,用于在 epoll_wait 返回时识别是哪个文件描述符触发了事件。
    int result = looper->addFd(looper->mWakeEventFd, LOOPER_ID_WAKE, EPOLLIN, nullptr);
    if (result != 0) {
        // ... 错误处理
    }

    return looper;
}

Looper 的构造函数中会调用 epoll_create1() 创建一个 epoll 实例,然后再调用 epoll_ctl() 给 epoll 实例添加一个唤醒事件文件描述符,把唤醒事件的文件描述符和监控请求的文件描述符添加到 epoll 的兴趣列表中。到这里消息机制的初始化就完成了。

消息轮询机制建立

ActivityThreadmain() 方法的最后,会调用 Looper.loop()。这个方法会使当前线程(主线程)进入一个无限循环。

这个循环中会调用 MessageQueue 的 next() 方法获取下一条消息,获取到消息后,loop() 方法就会调用 Message 的 target 的 dispatchMessage() 方法,target 其实就是发送 Message 的 Handler 。最后就会调用 MessagerecycleUnchecked() 方法回收处理完的消息。

如果消息队列为空,Looper 线程会进入休眠状态(这正得益于底层 epoll 和 eventfd 的机制),直到有新消息到来。

这个循环就是安卓 UI 线程保持“存活”和响应用户事件的基础。

MessageQueuenext() 方法中,首先会调用 nativePollOnce() 这个JNI方法,检查队列中是否有新的消息要处理

  • 如果没有,那么当前线程就会在执行到 Native 层的 epoll_wait() 时阻塞
  • 如果有消息,而且消息是同步屏障,那就会找出或等待需要优先执行的异步消息。调用完 nativePollOnce() 后,如果没有同步屏障,或者取到到了异步或非异步消息,就会判断消息是否到了要执行的时间,是的话则返回消息给 Looper 处理准备分发,不是的话就重新计算消息的执行时间(when)。

在把消息返回给 Looper 后,下一次执行 nativePollOnce()timeout 参数的值是默认的 0 ,所以进入 next() 方法时,如果没有消息要处理,next() 方法中还可以执行 IdleHandler 。在处理完消息后, next() 方法最后会遍历 IdleHandler 数组,逐个调用 IdleHandlerqueueIdle() 方法。

IdleHandler 可以用来做一些在主线程空闲的时候才做的事情,通过 Looper.myQueue().addIdleHandler() 就能添加一个 IdleHandler 到 MessageQueue 中。

以Java 层的 Looper.loop()MessageQueuenext() 方法为入口,这个消息循环的建立,就是 android::Looper 利用 epoll 高效地等待并处理事件的过程。

在 Native 层,消息循环的建立主要围绕着 android::Looper 类的 loop() 方法和其内部调用的 pollOnce() 方法展开。

1. Looper::loop() - 消息循环的入口

当你在 Java 层调用 Looper.loop() 方法时,它会通过 JNI(Java Native Interface) 机制,最终调用到 Native 层的 android::Looper::loop() 方法。

// Native层 Looper.cpp (简化版)
void Looper::loop() {
    // 确保当前线程已经准备好了一个Looper
    sp<Looper> me = Looper::getForThread(); // 获取当前线程的Looper实例
    if (me == nullptr) {
        // 错误处理:当前线程没有调用 Looper.prepare()
        return;
    }

    // 这是一个无限循环,Looper 会一直在这里运行,直到被 quit() 终止
    for (;;) { // 无限循环,除非 Looper 被终止
        // 核心:调用 pollOnce() 等待并处理事件
        int result = me->pollOnce(-1); // -1 表示无限等待,直到有事件发生

        switch (result) {
            case POLL_WAKE: // 由 wake() 方法唤醒,通常表示有新消息或Runnable
                // 如果是Looper被唤醒,但还没消息,则会在这里继续循环
                break;
            case POLL_MESSAGE: // 收到了要处理的消息
                // pollOnce 内部会处理 MessageQueue 中的消息
                break;
            case POLL_TIMEOUT: // 超时 (如果 pollOnce 设置了超时时间)
                break;
            case POLL_ERROR: // 发生错误
                break;
        }

        // 如果 Looper 被终止 (调用了 quit() 或 quitSafely())
        if (me->mExitWhenIdle) { // mExitWhenIdle 是一个标志,表示Looper是否应该退出
            break; // 退出循环
        }
    }
}

从代码中可以看出,Looper::loop() 的核心就是在一个 无限 for 循环 中不断地调用 me->pollOnce(-1)。这个 pollOnce 方法才是 真正执行阻塞、等待和初步事件处理 的地方。

2. Looper::pollOnce() - 阻塞、唤醒与事件分发

pollOnce() 方法是 Looper 消息循环的精髓所在。它利用了 epoll 来高效地等待事件,避免了忙等待。

// Native层 Looper.cpp (简化版)
int Looper::pollOnce(int timeoutMillis) {
    // 1. 处理待处理的消息 (如果有)
    // 首先检查 MessageQueue 中是否有即将到期或已经到期的消息需要处理。
    // 如果有,它会计算下一个消息的到期时间,并可能调整 epoll_wait 的超时时间。
    // 如果有立即需要处理的消息,直接返回 POLL_MESSAGE,不进入 epoll_wait。
    if (mMessageQueue->mNextBarrierToken != 0 || mMessageQueue->hasMessages(now)) {
        // 如果有消息,并且没到等待时间,会直接处理
        // 或者处理屏障消息,清理掉同步消息
        // ...
        timeoutMillis = 0; // 不阻塞,立即返回
    }

    // 2. 调用 epoll_wait 等待事件
    // mEpollFd 是在 Looper 初始化时创建的 epoll 实例文件描述符
    // events 是一个用于接收就绪事件的数组
    // EPOLL_MAX_EVENTS 是最大事件数
    // timeoutMillis 是等待的超时时间(-1 表示无限等待)
    int result = epoll_wait(mEpollFd, events, EPOLL_MAX_EVENTS, timeoutMillis);

    // 3. 处理 epoll_wait 返回的事件
    if (result < 0) { // epoll_wait 失败
        if (errno == EINTR) { // 被信号中断,继续循环
            return POLL_WAKE; // 被唤醒但没有明确事件
        }
        return POLL_ERROR; // 其他错误
    }

    // 4. 遍历就绪列表,处理事件
    for (int i = 0; i < result; i++) {
        epoll_event& event = events[i];
        int ident = event.data.u32; // 获取事件标识符 (Looper::addFd 时传入的)

        if (ident == LOOPER_ID_WAKE) { // 这是由 eventfd 触发的唤醒事件
            // 清除 eventfd 上的信号,以准备下一次唤醒
            uint64_t counter;
            ssize_t nread = read(mWakeEventFd, &counter, sizeof(uint64_t));
            // 唤醒通常意味着有新消息到达 MessageQueue,但具体消息由后续处理
            result = POLL_WAKE; 
        } else if (ident == LOOPER_ID_MESSAGE) { // 这是由 MessageQueue 自身触发的消息事件(不常用)
            // Android Looper主要通过 LOOPER_ID_WAKE 来感知消息,此分支较少触发
            result = POLL_MESSAGE;
        } else { // 处理其他文件描述符上的自定义事件
            // 如果Looper监听了其他自定义文件描述符(如管道、Socket),会在这里处理
            // 通常会调用与该fd关联的回调函数
            // ...
        }
    }

    // 5. 处理消息队列中的消息
    // 无论是否被 epoll 唤醒,都会再次检查并分发 MessageQueue 中的消息
    // 这是真正将消息从队列中取出并分发给对应 Handler 的地方。
    // 可能会调用 MessageQueue::next() 获取下一个消息,并调用 Handler 的 dispatchMessage()。
    if (mMessageQueue->hasMessages(now)) { // 再次检查是否有消息
         result = POLL_MESSAGE;
    }
    mMessageQueue->dispatchMessages(this, now); // 分发消息

    return result;
}

根据上面代码可以看出,调用了 Looper::loop() 后,即进入一个无限循环,在循环内部,不断调用 Looper::pollOnce(-1)

pollOnce() 函数 首先检查 MessageQueue 中是否有立即需要处理的消息。如果有,会立即处理而不阻塞。

核心逻辑 为调用 epoll_wait(mEpollFd, ...)。此时,Looper 线程会进入 睡眠状态,等待 mEpollFd 监听的任何文件描述符上发生事件。

  • 如果消息队列中有新消息,MessageQueue 会向 mWakeEventFd 写入一个字节。这会触发 mWakeEventFd 上的 EPOLLIN 事件。
  • epoll_wait() 检测到 mWakeEventFd 事件,并立即返回。
  • pollOnce() 遍历 epoll_wait() 返回的就绪事件列表。
  • 如果检测到 LOOPER_ID_WAKE 事件(即 mWakeEventFd 被写入),它知道 Looper 被唤醒了,通常意味着有新的消息需要处理。它会读取 eventfd 的值来清除信号。
  • 最后,pollOnce() 调用 mMessageQueue->dispatchMessages(),真正地从消息队列中取出消息,并通过 Handler.dispatchMessage() 分发给相应的 Handler 进行处理。

通过这种方式,在 Native 层构建了一个高效的事件循环。 利用 epoll 集中监听各种 I/O 事件,并用 eventfd 作为轻量级的内部唤醒信号,确保了在没有任务时线程可以休眠,而在有任务时能够被迅速、精确地唤醒,从而实现安卓系统流畅且低功耗的响应。

消息发送

插入:Message对象

Message 的实现。 Message 中的 what 是消息的标识符。而 arg1arg2objdata 分别是可以放在消息中的整型数据、Object 类型数据和 Bundle 类型数据。 when 则是消息的发送时间。

sPool 是全局消息池,最多能存放 50 条消息,一般建议用 Message 的 obtain() 方法复用消息池中的消息,而不是自己创建一个新消息。如果在创建完消息后,消息没有被使用,想回收消息占用的内存,可以调用 recycle() 方法回收消息占用的资源。如果消息在 Looper 的 loop() 方法中处理了的话, Looper 就会调用 recycleUnchecked() 方法回收 Message 。

消息发送

当我们用 HandlersendMessage() 、 sendEmptyMessage() 和 post() 等方法发送消息时,首先会创建或者复用一个 Message 对象。这个 Message 对象包含了消息类型、数据以及目标 Handler 等信息。

这几个发送消息的方法最终都会走到 HandlerenqueueMessage() 方法。 HandlerenqueueMessage() 又会调用 MessageQueueenqueueMessage() 方法。

enqueueMessage() 首先会判断,当没有更多消息、消息不是延时消息、消息的发送时间早于上一条消息这三个条件其中一个成立时,就会把当前消息作为链表的头节点,然后如果 IdleHandler 都执行完的话,就会调用 nativeWake() JNI 方法唤醒消息轮询线程。

如果上述三个条件都不成立,就会遍历消息链表,当遍历到最后一个节点,或者发现了一条早于当前消息的发送时间的消息,就会结束遍历,然后把遍历结束的最后一个节点插入到链表中。如果在遍历链表的过程中发现了一条异步消息,就不会再调用 nativeWake() JNI 方法唤醒消息轮询线程。

这一步可以确定当前发送的消息应该放到消息链表的哪个位置

消息处理

从 MessageQueue 中取出消息

一旦 pollOnce() 被唤醒并返回,Native Looper 会调用 Native MessageQueue 的相应方法(例如 next() )。MessageQueue 会:

  • 加锁保护: 同样,在访问消息队列时会进行加锁操作(例如 pthread_mutex_lock),确保线程安全。
  • 遍历链表: 从内部的链表结构中取出最需要处理的那个消息(即 when 值最小且已到期的消息)。
  • 移除消息: 将取出的消息从队列中移除。
  • 解锁: 释放锁。

将 Native 消息转回 Java Message

Native Looper 取出 Native Message 对象后,需要将其封装回 Java 层的 Message 对象,以便 Java 层的 Handler 能够理解和处理。这通常通过 JNI 调用来实现,将 Native Message 的数据(如 what, arg1, arg2, obj 指针等)填充到 Java Message 对象中。

派发消息到 Java 层

一旦 Java Message 对象准备好,Native 层会再次通过 JNI 调用 Java Message 对象的 target (也就是 Handler) 的 dispatchMessage() 方法。

// 简化示意,非完整代码
// 在 Native Looper 的 C++ 代码中,执行类似以下的操作
jniEnv->CallVoidMethod(javaMessageObj, dispatchMessageMethodID);

dispatchMessage

最后,消息回到了 Java 层,由目标 Handler 的 dispatchMessage() 方法接收。dispatchMessage() 方法会根据消息的 callback (通过 post() 方法发送的 Runnable) 或 what 值,最终调用重写的 handleMessage() 方法来处理具体的业务逻辑。

此前在 HandlerenqueueMessage() 方法中,会设置 Messagetarget 为当前 Handler 对象。

HandlerdispatchMessage() 方法的优先级顺序:

  • 如果 Message.callback(即 post(Runnable) 的 Runnable)不为空,执行 callback.run()。
  • 如果 Handler.mCallback(Handler 的构造函数传入的 Callback 对象)不为空,调用 mCallback.handleMessage(msg)。
  • 否则调用 Handler.handleMessage(msg)。

经典问题点

postDelay消息是如何实现的?

当你使用 Handler.postDelayed(Runnable r, long delayMillis)Handler.sendMessageDelayed(Message msg, long delayMillis) 发送延时消息时,Handler 机制会利用底层的一些巧妙设计来确保消息在指定的时间后才被处理。其核心在于 消息队列的排序Looper 的休眠/唤醒机制

下面我们来深入了解一下 postDelayed 的底层实现原理:

消息的封装与时间戳

当你调用 postDelayed 时,Handler 会创建一个 Message 对象(如果是 postDelayed(Runnable r, ...),Runnable 会被封装到 Message 的 callback 字段中)。这个 Message 对象会被赋予一个关键属性:when

when 字段表示的是消息应该被处理的 绝对时间,计算方式是:

when = SystemClock.uptimeMillis() + delayMillis

SystemClock.uptimeMillis():返回系统开机以来的毫秒数,不包括深度睡眠的时间。这是一个稳定的、适合计算延时的时钟源。delayMillis就是你指定的延时时长。

所以,when 字段就存储了这条延时消息的“到期时间”。

消息入队与排序

MessageQueue 并不是一个简单的 FIFO (先进先出)队列,它实际上是一个 有序队列,消息会根据它们的 when 值进行插入,确保队列中的消息始终按 when 值从小到大(即按到期时间从早到晚)的顺序排列。

当一个延时消息被发送并准备入队时,MessageQueue 会遍历现有消息,将其插入到正确的位置,以保持队列的有序性。这意味着,到期时间最早的消息总是在队列的头部。

Looper 的休眠与唤醒

这是实现延时消息的关键部分。Looper 在它的无限循环中,会不断地调用 MessageQueue.next() 方法来获取下一个要处理的消息。此方法会检查队列头部的消息。

  • 如果队列头部有消息,并且该消息的 when 值已经小于或等于当前 SystemClock.uptimeMillis()(即消息已到期),那么 next() 方法会立即返回该消息,Looper 会立即处理它。
  • 如果队列头部有消息,但它的 when 值大于当前时间(即消息还未到期),next() 方法会计算一个 nextPollTimeoutMillis。这个超时时间就是当前时间到队列头部消息到期时间之间的时间差。这个超时时间会告诉底层的阻塞机制,Looper 最多可以休眠多久。
nextPollTimeoutMillis = 队列头部消息的when - SystemClock.uptimeMillis()

Native 层的阻塞:

计算出 nextPollTimeoutMillis 后,MessageQueue.next() 会调用到其 Native 层的实现。在 Native 层,Looper 会利用 Linux 内核的 epoll_wait 机制,传入这个 nextPollTimeoutMillis 作为超时参数。

  • 如果 nextPollTimeoutMillis 大于 0: Looper 线程会进入阻塞状态,最长休眠 nextPollTimeoutMillis 毫秒。这意味着线程会暂停执行,不会消耗 CPU 资源,直到:
    • 指定的时间过去(消息到期)。
    • 有新的消息入队(新的消息可能到期时间更早,需要提前唤醒)。
    • 有其他文件描述符事件发生(例如,用户输入、网络数据到达等)。
  • 如果 nextPollTimeoutMillis 小于或等于 0: 说明队列头部的消息已经到期或没有延时,Looper 会立即返回并处理消息,不会阻塞。

唤醒与消息处理

当 Looper 休眠的时间达到 nextPollTimeoutMillis 后,它会自动被系统唤醒(系统直接写eventfd描述符,通过epoll链路通知)。唤醒后,它会再次调用 MessageQueue.next(),此时原先的延时消息应该已经到期,于是被取出并分发处理。

新消息提前唤醒: 如果在 Looper 休眠期间,有新的消息入队,并且这个新消息的 when 值比当前队列头部消息的 when 值更早(即新消息需要更早处理),或者 Looper 根本就没有休眠,那么 MessageQueue 会通过直接写入数据的方式,立即 唤醒 正在休眠的 Looper 线程。Looper 线程被唤醒后,会重新计算下一次休眠时间,或者直接处理更早到期的消息。

【Android进阶】Binder机制通信原理简介

【Android进阶】Binder机制通信原理简介

本文介绍了Android Binder机制通信流程和背后的原理

Android Binder 机制是 Android 系统中非常核心的 进程间通信(IPC)机制,它在 Android 各种组件之间的通信中扮演着至关重要的角色,比如 App和系统服务之间 的通信、两个App之间的通信等。理解 Binder 机制是深入学习 Android 系统的重要一步。

此前对于Binder的了解仅仅停留在使用层面,没有去了解其背后的原理,现对其更加深入地学习一下,本文将介绍Android Binder机制通信流程和背后的原理。

进程间通信需求由来

Android系统是基于Linux系统而开发的,也继承了Linux的 进程隔离机制 。进程之间是无法直接进行交互的,每个进程内部各自的数据无法直接访问。

操作系统层面上,系统为了防止上层进程直接操作系统资源,造成数据安全和系统安全问题,系统内核空间和用户空间也是分离开来的,保证用户程序进程崩溃时不会影响到整个系统。简单的说就是,分离后,内核空间(Kernel)是系统内核运行的空间,用户空间(UserSpace)是用户程序运行的空间。

两个用户空间的进程,要进行数据交互,也需要通过内核空间来驱动整个过程。进程间的通信,即 Inter-Process Communication(IPC)

在 Linux 系统中,常见的 IPC 方式有管道、消息队列、共享内存、信号量和Socket等。然而,Android 并没有直接使用这些机制作为主要的 IPC 方式,而是选择了 Binder。这主要是出于以下几点考虑:

  • 性能优化: Binder 机制在设计上针对移动设备进行了优化,相比 Socket 等方式,其数据拷贝次数更少,效率更高。传统的 IPC 方式(如管道、消息队列)通常需要两次内存复制,而 Binder只需要一次复制 (从用户空间写到内核空间,目标用户空间可以直接通过内核缓冲区读取)。
  • 安全性: Binder 机制从底层提供 UID/PID 认证,可以方便地进行权限控制,确保通信的安全性。这对于 Android 这种多应用、多用户环境非常重要。
  • 架构优势: Binder 机制基于 C/S (Client/Server) 架构,使得服务提供者和使用者之间解耦,更容易进行系统设计和扩展。
  • 内存管理: Binder 机制在内核层实现了内存的映射和管理,能够更好地处理大块数据的传输。

内存映射

虽然用户地址空间是不能互相访问的,但是不同进程的内核地址空间是映射到相同物理地址的,它们是相同和共享的,我们可以借助内核地址空间作为中转站来实现进程间数据的传输。

进程 B 通过 copy_from_user 往内核地址空间里写值,进程 A 通过 copy_to_user 来获取这个值。可以看出为了共享这个数据,需要两个进程拷贝两次数据。

我们可以通过 mmap 将 进程 A 的用户地址空间与内核地址空间进行映射 ,让他们指向相同的物理地址空间,这样就只需要进行一次拷贝。

当进程 B 调用一次 copy_from_user 时,会将数据从用户空间拷贝到内核空间,而进程 A 和这个内核空间映射了同一个物理地址,故进程 A 可以直接使用这个数据,而无需再次拷贝。

Binder通信的内存映射机制

每个使用 Binder 的进程在启动时,会通过 mmap() 系统调用,向 Binder 驱动申请一块内存映射区域。驱动响应这个调用, 在内核空间分配一块物理页面,同时将其映射到该进程的用户空间和内核空间 。这样,每个进程都有自己对应的 Binder 映射缓冲区。

这块内核缓冲区会被 mmap() 映射到该进程的用户空间地址。

通信开始时,客户端进程将要发送的数据(Parcel)写入其用户空间中映射的这块共享内存区域(实际是写入到 Binder 驱动分配的内核缓冲区)。数据从客户端的用户空间拷贝到Binder 驱动的内核缓冲区,这是第一次拷贝。

由于数据已经在内核缓冲区中。Binder 驱动并不需要将数据从内核缓冲区再次拷贝到目标进程的用户空间。驱动程序通过 页表机制(Page Table) 的调整,将内核缓冲区中的数据直接映射到服务端进程的用户空间。

服务端返回数据时同理。

Binder通信过程的四个主要角色

  • Server,服务的提供者,实现具体的业务逻辑。
  • Client,客户端, 服务的使用者。
  • Service Manager ,负责注册和查找服务。当服务端启动时,会向 Service Manager 注册自己的 Binder 对象。在客户端需要查找服务时,则通过 Service Manager 获取对应服务的 Binder 代理对象。
  • Binder 驱动,这是整个 Binder 机制的核心,它是 Linux 内核中的一个字符设备驱动程序。它负责完成进程间的数据传输和进程线程的调度。所有 Binder 通信都必须通过 Binder 驱动。

Service Manager介绍

ServiceManager 是 Android Binder 进程间通信(IPC)机制的核心组成部分之一。简单来说,它就像一个服务注册中心黄页。当系统中的各种服务(例如 ActivityManagerService、PackageManagerService 等)启动时,它们会将自己注册到 ServiceManager 中。其他进程如果需要使用这些服务,就可以通过 ServiceManager 查询并获取 到对应的 Binder 代理对象,进而与服务进行通信。

在 Android 早期版本中,ServiceManager 是一个独立的进程,但在现代 Android 版本中,它通常被集成在 init 进程中,作为 servicemanager 可执行文件运行。它监听一个固定的 Binder 端口(通常是 0),作为所有其他 Binder 通信的入口。

它为所有系统服务提供了一个统一的查找和访问入口,简化了服务的管理和调用。在 Android 系统的启动过程中, ServiceManager 是最先启动的核心服务之一,因为它为后续其他关键服务的启动和交互提供了基础。

Binder驱动介绍

Binder 驱动在通信过程中主要扮演了以下几个关键角色:

第一点就是刚刚提到的 分配共享缓冲区 。当任何一个用户进程(客户端或服务端)第一次打开 Binder 设备时,Binder 驱动会通过 mmap() 系统调用,为这个进程在内核中分配一块连续的物理内存作为共享缓冲区

驱动程序会将这块内核缓冲区分别映射到该进程的用户空间。这样,数据在客户端和驱动之间、以及驱动和服务端之间传输时,就可以通过共享内存机制实现高效的传输。

第二点为路由和转发,驱动程序维护着所有使用 Binder 的进程、这些进程中的 Binder 线程池、以及所有 Binder 实体(服务对象)的内部映射表

当客户端通过 ioctl() 系统调用向驱动发送请求时,它会携带一个表示目标服务对象的句柄 (Handle)。驱动程序根据这个句柄,查询内部映射表,精确地找到对应的目标服务端进程和目标 Binder 实体(Stub),然后将请求数据转发给该进程。

第三是线程管理和调度,每个服务端进程都会向 Binder 驱动“注册”一组专用于处理 IPC 请求的线程(即 Binder 线程池)。当驱动程序接收到发往某个服务端的请求时,它会唤醒服务端进程中空闲的 Binder 线程,让它去处理这个请求。如果所有 Binder 线程都在忙碌,驱动可能会指示服务端创建一个新的 Binder 线程来处理请求(直到达到最大线程数限制)。驱动确保每个传入的请求都能被服务端的某个 Binder 线程排队和处理

最后一点为安全管理,驱动程序在处理事务时,会记录并传递调用进程的 UID/PID 等身份信息。这为上层(如 Android Framework)进行权限检查(例如,判断调用者是否有权限调用某个服务)提供了底层信任的基础数据。

两个关键自动生成类 Stub 和 Proxy

aidl接口声明示例文件:

package com.stephen.commondebugdemo;

interface ICalculateTest {
    void setRemoteValue(int value);
    int getRemoteValue();
}

在定义完了AIDL文件,确定好方法之后,编译器会根据AIDL文件自动生成 Stub 和 Proxy 类。

Stub类

Stub 类是 AIDL 封装机制中的服务端核心,它承担了将底层 IPC 机制与上层业务逻辑连接起来的全部工作。它是一个抽象的内部类,其完整声明通常是:

public static abstract class Stub extends android.os.Binder implements com.stephen.commondebugdemo.ICalculateTest

可以看到,Stub 类继承自 android.os.Binder,这赋予了它作为 Binder 实体对象 的能力。当服务进程将 Stub 的实现对象注册给 ServiceManager 后,它就代表了服务端的具体功能。当客户端发起 IPC 调用时,请求会直接被路由到这个 Binder 对象上。

实现预定义的 CalculateTest 接口,要求所有继承 Stub 的具体服务类必须实现 AIDL 接口中定义的所有业务方法。

Stub 核心工作:处理客户端请求(onTransact 方法)

Stub 类最重要、最复杂的工作就是重写了 Binder 类的 onTransact() 方法。这是所有远程调用请求进入服务端的唯一入口。

/**
 * 处理客户端请求的核心方法。
 * @Param code 客户端请求的方法标识符
 * @Param data 包含客户端传递过来的方法参数的 `Parcel` 对象。
 * @Param reply 用于将服务端方法返回值或异常信息打包回客户端的 `Parcel` 对象。
 * @Param flags 用于控制 IPC 调用行为的标志位(例如 `FLAG_ONEWAY` 表示异步调用)。
 * 
*/
@Override public boolean onTransact(
    int code,
    android.os.Parcel data, 
    android.os.Parcel reply,
    int flags) throws android.os.RemoteException

详细代码如下:

@Override
public boolean onTransact(int code, android.os.Parcel data, android.os.Parcel reply, int flags) throws android.os.RemoteException {
    java.lang.String descriptor = DESCRIPTOR;
    // 检查调用是否来自预期的接口
    if (code >= android.os.IBinder.FIRST_CALL_TRANSACTION && code <= android.os.IBinder.LAST_CALL_TRANSACTION) {
        data.enforceInterface(descriptor);
    }
    // 处理特殊的接口查询事务
    if (code == INTERFACE_TRANSACTION) {
        reply.writeString(descriptor);
        return true;
    }
    // 处理具体的业务方法调用
    switch (code) {
        case TRANSACTION_setRemoteValue: {
            int _arg0;
            // 从 data 中读取客户端传递的参数 _arg0
            _arg0 = data.readInt();
            // 调用服务端内部的 setRemoteValue 方法,将客户端传递的参数 _arg0 设置为服务端的状态
            this.setRemoteValue(_arg0);
            // 写入无异常标志位,通知客户端调用成功
            reply.writeNoException();
            break;
        }
        case TRANSACTION_getRemoteValue: {
            // 获取数据写入到 reply 中
            int _result = this.getRemoteValue();
            reply.writeNoException();
            reply.writeInt(_result);
            break;
        }
        default: {
            return super.onTransact(code, data, reply, flags);
        }
    }
    return true;
}

onTransact 方法的工作流如下:

  1. 方法识别 (code): 根据传入的 code(一个整数标识符,对应 AIDL 中的方法),判断客户端想调用哪个方法。
  2. 数据解包 (data): 从输入的 Parcel 对象 (data) 中,按照约定的顺序和类型,逐一读取客户端传递过来的方法参数。
  3. 调用业务逻辑: 调用开发者在 Stub 子类中实现的对应方法(例如 this.methodA(...))。
  4. 结果打包 (reply): 将业务方法返回的结果(以及可能的异常信息)写入到输出的 Parcel 对象 (reply) 中。
  5. 返回结果: reply 对象通过 Binder 驱动回传给客户端进程。

Parcel 是 Android 工程师为了解决 IPC 效率问题而专门设计的一种序列化容器。它牺牲了通用性,换来了极高的性能,成为 Android Binder 机制中不可或缺的基石。在 AIDL 中,Proxy 和 Stub 所做的大量工作,就是围绕 Parcel 的读写来进行的。

asInterface() 连接客户端与服务(asInterface 静态方法)
/**
 * Cast an IBinder object into an com.stephen.commondebugdemo.ICalculateTest interface,
 * generating a proxy if needed.
 */
public static com.stephen.commondebugdemo.ICalculateTest asInterface(android.os.IBinder obj)
{
    if ((obj==null)) {
        return null;
    }
    android.os.IInterface iin = obj.queryLocalInterface(DESCRIPTOR);
    if (((iin!=null)&&(iin instanceof com.stephen.commondebugdemo.ICalculateTest))) {
        return ((com.stephen.commondebugdemo.ICalculateTest)iin);
    }
    return new com.stephen.commondebugdemo.ICalculateTest.Stub.Proxy(obj);
}

这个方法是客户端获取服务端代理对象的桥梁。客户端通过 ServiceConnectiononServiceConnected() 拿到一个原始的 IBinder 对象。

如果传入的 obj 是一个 Stub 实例(即客户端和服务端在同一个进程),则直接返回这个 Stub 实例本身。

远程调用 (跨进程),如果传入的 obj 来自另一个进程,它会创建并返回一个 Proxy 代理对象,将这个远程 IBinder 封装起来。

这样,客户端调用者无需关心目标对象是否在本地,只需调用 asInterface() 即可得到一个统一的 IMyAidlInterface 接口对象。

服务端使用Stub的示例代码
class CalculateTestService : Service() {

    private var internalTestValue = 0

    private val binder = object : CalculateTest.Stub() {
        override fun setRemoteValue(value: Int) {
            internalTestValue = value
        }

        override fun getRemoteValue(): Int {
            return internalTestValue
        }
    }

    override fun onBind(intent: Intent) = binder
}

Proxy类

Proxy 类是 AIDL 封装机制中的客户端核心,它负责将客户端对接口方法的调用,转化为底层 Binder 机制所需的 IPC 请求。

它通常是 Stub 类内部的一个静态私有子类,其结构通常是下面这样,实现了所定义的AIDL服务接口。

private static class Proxy implements com.stephen.commondebugdemo.ICalculateTest {
    private android.os.IBinder mRemote;

    Proxy(android.os.IBinder remote) {
        mRemote = remote;
    }

    @Override
    public android.os.IBinder asBinder() {
        return mRemote;
    }

    public java.lang.String getInterfaceDescriptor() {
        return DESCRIPTOR;
    }

    ...
}

它的核心工作可以分为以下几个方面:

结构与初始化:持有远程引用

实现 ICalculateTest 接口,这使得 Proxy 实例可以被客户端代码当作真正的 AIDL 接口对象来调用,从而实现了透明代理

Proxy 持有 IBinder 对象 (mRemote字段)。这个 IBinder 对象是客户端通过 ServiceConnection 类,在服务连接成功后,调用 asInterface() 传入构造函数的。它代表了服务端的那个真正的 Stub 实体在客户端进程中的引用(Binder Token)。所有的 IPC 通信都是通过这个 mRemote 对象进行的。

核心工作:发起远程调用(transact 方法)

Proxy 类实现了 AIDL 接口中定义的所有方法,但这些实现方法的内部是统一的 IPC 封装逻辑,直接调用 transact() 方法。

例如 setRemoteValue()

@Override
public void setRemoteValue(int value) throws android.os.RemoteException {
    // 后面将方法参数写入 _data 中
    android.os.Parcel _data = android.os.Parcel.obtain();
    // 从reply中读取异常信息和返回值
    android.os.Parcel _reply = android.os.Parcel.obtain();
    try {
        _data.writeInterfaceToken(DESCRIPTOR);
        _data.writeInt(value);
        boolean _status = mRemote.transact(Stub.TRANSACTION_setRemoteValue, _data, _reply, 0);
        _reply.readException();
    } finally {
        // 回收 Parcel 对象
        _reply.recycle();
        _data.recycle();
    }
}

各实现方法简单来说就是数据的打包和发送。等待服务端处理完毕后,从返回的 Parcel 中读取服务端的执行结果(返回值或异常信息)。

辅助验证方法 getInterfaceDescriptor

getInterfaceDescriptor() 提供了 AIDL 接口的唯一标识字符串,用于在通信双方进行接口校验。

客户端获取AIDL服务实例代码
object ClientProxy {

    private lateinit var calculateTest: ICalculateTest

    private val serviceConnection = object : ServiceConnection {
        override fun onServiceConnected(
            name: ComponentName?,
            service: IBinder?
        ) {
            calculateTest = ICalculateTest.Stub.asInterface(service)
        }

        override fun onServiceDisconnected(name: ComponentName?) {
            infoLog("onServiceDisconnected")
        }
    }

    ...

    // connect and call methods
}

Binder通信简化流程

通信架构示意图:

Binder 通信的流程可以分为以下几步:

  1. 注册服务:
    • Server 服务端在启动时,会向 Service Manager 注册自己提供的服务及其对应的 Binder 对象。
    • 这个注册过程其实也是一次 Binder 通信,Server 作为 Client 向 Service Manager 发送注册请求。
  2. 获取服务:
    • Client 端(例如:某个 App)需要使用某个服务时,会通过 Service Manager 查询对应的服务,获取其 Binder 代理对象。
    • 这个获取过程也是一次 Binder 通信,Client 作为 Client 向 Service Manager 发送查询请求。
  3. Client 调用服务:
    • Client 获取到服务的 Binder 代理对象后,就可以通过这个代理对象调用服务端的具体方法。
    • 当 Client 调用代理对象的方法时,实际是把请求数据打包(marshalling)并通过 Binder 驱动 发送给 Server 端。
    • Binder 驱动将数据从 Client 进程的用户空间拷贝到内核空间,然后根据目标进程的 Binder 对象,再将数据从内核空间映射到 Server 进程的用户空间。
    • Server 端收到请求后,会解包(unmarshalling)数据,然后执行对应的服务方法,并将结果返回给 Client。这个返回过程也是通过 Binder 驱动进行的。

一般来说,Client 进程访问 Server 进程函数,我们需要在 Client 进程中 按照固定的规则打包数据 ,这些数据包含了:

  • 数据发给哪个进程,Binder 中是一个整型变量 Handle
  • 要调用目标进程中的那个函数,Binder 中用一个整型变量 Code 表示目标函数的参数
  • 要执行具体什么操作,也就是 Binder 协议

Client 进程通过 IPC 机制将数据传输给 Server 进程。当 Server 进程收到数据,按照固定的格式解析出数据,调用函数,并使用相同的格式将函数的返回值传递给 Client 进程。

通信关系图:

binder_node

binder_node 是应用层的 service 在内核中的存在形式 ,是内核中对应用层 service 的描述,在内核中具体表现为 binder_node 结构体。

在上图中,ServiceManager 在 Binder 驱动中有对应的的一个 binder_node(Binder 实体)

每个 Server 在 Binder 驱动中也有对应的的一个 binder_node(Binder 实体)

这里假设每个 Server 内部仅有一个 service,内核中就只有一个对应的 binder_node(Binder 实体),实际可能存在多个。

binder_node 结构体中存在一个指针 struct binder_proc *proc; ,指向 binder_node 对应的 binder_proc 结构体。

// binder_node 是内核中对应用层 binder 服务的描述
struct binder_node {
	//......
	struct binder_proc *proc;
	//......
}

binder_proc

binder_proc 是内核中对应用层进程的描述,内部有众多重要数据结构。

// binder_proc 是内核中对应用层进程的描述
struct binder_proc {
	//......
	struct binder_context *context;
	//......
}

binder_ref(Binder 引用)

所谓 binder_ref(Binder 引用),实际上是内核中 binder_ref 结构体的对象,它的作用是在表示 binder_node(Binder 实体) 的引用。

换句话说,每一个 binder_ref(Binder 引用) 都是某一个 binder_node (Binder实体)的引用 ,通过 binder_ref(Binder 引用) 可以在内核中找到它对应的 binder_node(Binder 实体)。

寻址

Binder 是一个 RPC 框架,最少会涉及两个进程。那么就涉及到寻址问题,所谓寻址就是当 A 进程需要调用 B 进程中的函数时,怎么找到 B 进程。

Binder 中寻址分为两种情况:

  • ServiceManager 寻址,即 Client/Server 怎么找到 ServiceManager,对应于内核,就是找到 ServiceManager 对应的 binder_proc 结构体实例
  • Server 寻址,即 Client 怎么找到 Server,对应于内核,就是找到 Server 对应的 binder_proc 结构体实例

如何找到ServiceManager

系统启动时,Service Manager 启动并注册自身:

  • 在 Android 系统启动初期,servicemanager 进程(一个守护进程)会首先启动。
  • 它会打开 /dev/binder 设备文件,获取一个文件描述符 (fd)。 *然后,它会通过一个特殊的 ioctl 系统调用 BINDER_SET_CONTEXT_MGR 将自己注册为 Binder 驱动的 “上下文管理器” 。这个操作会告诉 Binder 驱动,Service Manager 是那个负责管理所有其他 Binder 服务的特殊实体。
  • 在这个注册过程中,Binder 驱动会为 Service Manager 分配句柄 0。从此以后,Service Manager 就成为了 Binder 驱动中唯一拥有句柄 0 的实体

每个使用 binder 的进程,在初始化时,会在内核中将 binder_device 的 context 成员赋值给 binder_proc->context

binder_proc->context = binder_device->context;

binder_device 指的是 Binder 驱动程序在 Linux 内核中提供的设备文件,而 binder_device是全局唯一变量,这样的话,所有进程的 binder_proc->context 都指向同一个结构体实例。

ServiceManager 调用 binder_become_context_manager 后,会陷入内核,在内核中会构建一个 binder_node 结构体实例,构建好以后,会将他保存到 binder_proc->context->binder_context_mgr_node 中。

也就是说,任何时候我们都可以通过 binder_proc->context->binder_context_mgr_node 获得 ServiceManager 对应的 binder_node 结构体实例。 binder_node 结构体中有一个成员 struct binder_proc *proc;,通过这个成员我们就能找到 ServiceManager 对应的 binder_proc.

当任何其他 Binder 进程(Client 或 Server,在向 Service Manager 注册服务时,它也充当 Service Manager 的 Client)需要与 Service Manager 通信时,它们并不需要“查找” Service Manager。

它们可以直接通过 Binder 框架提供的 API 获取一个代表 Service Manager 的 IBinder 代理对象。 这个代理对象内部封装的 Binder 句柄值就是 0。

如何找到Server

服务注册阶段

Server 端向 ServiceManager 发起注册服务请求时(svcmgr_publish),会陷入内核,首先通过 ServiceManager 寻址方式找到 ServiceManager 对应的 binder_proc 结构体,然后在内核中构建一个代表待注册服务的 binder_node 结构体实例,并插入服务端对应的 binder_proc->nodes 红黑树中

接着会构建一个 binder_ref 结构体binder_ref 会引用到上一阶段构建的 binder_node ,并插入到 ServiceManager 对应的 binder_proc->refs_by_desc 红黑树 中,同时会计算出一个 desc 值(1,2,3 ….依次赋值)保存在 binder_ref 中。

最后服务相关的信息(主要是名字和 desc 值)会传递给 ServiceManager 应用层,应用层通过一个链表将这些信息保存起来。

服务获取阶段

Client 端向 ServiceManager 发起获取服务请求时(svcmgr_lookup,请求的数据中包含服务的名字),会陷入内核, 通过 binder_proc->context->binder_context_mgr_node 寻址到 ServiceManager,接着通过分配映射内存,拷贝数据后,将 “获取服务请求” 的数据发送给 ServiceManager

ServiceManager 应用层收到数据后,会遍历内部的链表,通过传递过来的 name 参数,找到对应的 handle,然后将数据返回给 Client 端,接着陷入内核,通过 handle 值在 ServiceManager 对应的 binder_proc->refs_by_desc 红黑树中查找到服务对应 binder_ref,接着通过 binder_ref 内部指针找到服务对应的 binder_node 结构。

接着 创建出一个新的 binder_ref 结构体实例,内部 node 指针指向刚刚找到的服务端的 binder_node,接着再 将 binder_ref 插入到 Client 端的 binder_proc->refs_by_desc ,并计算出一个 desc 值(1,2,3 ….依次赋值),保存到 binder_ref 中。desc 值也会返回给 Client 的应用层。

Client 应用层收到内核返回的这个 desc 值,改名为 handle ,接着向 Server 发起远程调用,远程调用的数据包中包含有 handle 值,接着陷入内核,在内核中首先根据 handle 值在 Client 的 binder_proc->refs_by_desc 获取到 binder_ref ,通过 binder_ref 内部 node 指针找到目标服务对应的 binder_node,然后通过 binder_node 内部的 proc 指针找到目标进程的 binder_proc ,这样就完成了整个寻址过程。

oneway机制

简单来说,oneway 是一种异步调用机制。当你通过Binder调用远程进程中的方法时,如果该方法被标记为 oneway,那么:

  • 调用者(Client)会立即返回,而不会等待被调用者(Server)执行完方法并返回结果。 调用请求会被发送到远程进程,但调用者线程不会被阻塞。
  • 被调用者(Server)会在一个单独的Binder线程池中处理这个 oneway 请求。 这意味着 oneway 调用不会阻塞服务端的UI线程或其他重要线程。

oneway 方法不能有返回值。 因为调用者不会等待结果,所以也就没有返回值的意义。如果需要执行结果,可以设置一个callback。

在声明AIDL接口时,将返回值类型设置为oneway既可。

// IMyService.aidl
package com.example.myapp;

interface IMyService {
    // 同步方法,会等待返回值
    int add(int a, int b);

    // oneway 方法,客户端不会等待其执行完成
    oneway void doSomethingAsync(String message);

    // 如果整个接口都是oneway的,也可以直接在接口前面声明
    // oneway interface IAnotherService {
    //     void notifyEvent(int eventCode);
    // }
}

Binder 通信大小限制

Binder 调用中同步调用优先级大于 oneway(异步)的调用,为了充分满足同步调用的内存需要,所以将 oneway 调用的内存限制到申请内存上限的一半。

Android系统 中大部分IPC场景是使用 Binder 作为通信方式,在进程创建的时候会为 Binder 创建一个1M 左右的缓冲区用于跨进程通信时的数据传输,如果超过这个上限就就会抛出这个异常,而且这个缓存区时当前进程内的所有线程共享的,线程最大数量为16(线程池 15 个子线程 + 1 个主线程)个,如同时间内总传输大小超过了1M,也会抛异常。

另外在 Activity 启动的场景比较特殊,因为 Binder 的通信方式为两种,一种是异步通信,一种是同步通信,异步通信时数据缓冲区大小被设置为了原本的1半。

【Android进阶】Material Design设计理念和配置方式

【Android进阶】Material Design设计理念和配置方式

本文介绍了Google推出的UI设计系统——Material Design的设计理念和配置方式。

Material Design(材料设计) 是由 Google 在 2014 年推出的设计语言,旨在为多平台(包括移动端、Web、桌面端等)提供统一、直观且具有物理感的视觉与交互体验。它融合了现实世界的物理规律(如阴影、层次感)与数字交互的灵活性,强调“材料”作为设计的基本单元,通过清晰的视觉层次、响应式动画和一致的交互逻辑,提升用户对产品的认知效率与情感共鸣。

目前已经不仅仅在 Android 系统上应用,还扩展至 Web(Material Web)、Flutter(跨平台开发框架)、iOS(通过 Material Components for iOS)等平台,实现真正的“一次设计,多端适配”。

共推出过三个大版本:

  • Material Design 1.0(2014):奠定基础,强调卡片、阴影和动效。
  • Material Design 2.0(2018,Material Theming):引入动态主题(Dynamic Color),支持品牌个性化定制。
  • Material Design 3(2021,Material You):以用户为中心的设计,核心是“自适应色彩”(用户可基于壁纸提取主色,系统自动适配界面配色),增强个性化与无障碍体验。

Google 提供了丰富的设计资源(如 Material Design Guidelines、Figma 组件库),开发者社区(如 GitHub)也有大量开源实现。

对于设计师,设计工具诸如 Adobe XD、Sketch等,也可以集成 Material 插件,支持快速原型设计。

Material Design以其统一的设计语言降低开发与设计成本,提升跨团队协作效率。同时,自适应设计与动态主题的特点,可以满足多设备、个性化需求。

但是,也有一大部分设计师和开发者认为其规范过于严格,可能限制创新设计。而且过度依赖阴影和层次感可能导致界面视觉复杂度增加(尤其在低配设备上)。

一、核心理念:数字世界的“材料”隐喻

Material Design 的核心是将数字界面类比为现实世界中的“材料”(如纸张、墨水),但赋予其数字化特性(如无限延展性、动态响应)。这种隐喻并非追求完全模拟物理世界,而是通过“材料”的抽象化表达(如阴影表示层次、颜色传递状态),让用户快速理解界面元素的逻辑关系,降低认知成本。

二、设计原则:四大支柱

  1. 真实感与层次感(Material as Metaphor)
    • 以“材料”为基本单元,通过阴影深度(elevation)表现元素的层级关系(如卡片浮于背景之上)。
    • 颜色、形状和动效模拟现实中的光影变化,增强界面的物理真实感,但避免过度拟物化。
  2. 直观的动效(Bold, Graphic, Intentional)
    • 动效不仅是装饰,而是传递信息的工具。例如,按钮点击时的微缩反馈、页面切换时的滑动过渡,均需明确提示用户操作结果。
    • 动效需符合物理规律(如惯性、缓动),避免突兀变化。
  3. 响应式交互(Motion Provides Meaning)
    • 界面元素需对用户操作(点击、滑动、拖拽)做出即时反馈,例如输入框获得焦点时的放大效果、列表项拖动时的实时位置更新。
    • 动效需引导用户注意力,帮助理解界面状态的变化(如加载中的进度指示)。
  4. 跨平台一致性(Adaptive Design)
    • 通过统一的视觉语言(颜色、排版、图标)和交互模式,确保在不同设备(手机、平板、桌面、可穿戴设备)和场景(横屏、竖屏、暗黑模式)下保持体验连贯性。
    • 支持自适应布局,根据屏幕尺寸动态调整组件排列(如网格系统的灵活适配)。

三、关键设计元素

  1. 材料(Material Surfaces)
    • 界面由多层“材料”构成,每层具有独立的阴影深度(elevation),通过阴影差表现叠加关系(如对话框浮于卡片之上)。
    • 材料可伸缩、变形,但不可穿透(遵循现实世界的物理规则)。
  2. 颜色与排版
    • 颜色:以主色(Primary Color)和强调色(Accent Color)为核心,搭配中性色(黑白灰)构建层次。Google 提供了一套标准色板(如 Material Color System),支持动态配色(Dark Theme)。
    • 排版:基于 Roboto 字体(后扩展至其他开源字体),通过字号、字重(Bold/Light)和行高构建清晰的文本层级,确保可读性与信息密度平衡。
  3. 图标与图形
    • 使用线性图标(Material Icons)和填充图标,强调简洁性与符号化表达。图标需符合用户认知习惯(如“菜单”用三条横线表示)。
    • 图形设计注重几何形状(圆形、矩形)的组合,通过圆角、边框和阴影增强视觉层次。
  4. 动效系统(Motion System)
    • 容器变换(Container Transform):元素变形时保持视觉连续性(如卡片展开为详情页)。
    • 共享轴(Shared Axis):通过共用的运动方向(如左右滑动切换标签页)传递元素关联性。
    • 淡入淡出(Fade):用于非关联元素的切换(如提示信息消失)。
    • Google 提供了 Lottie 等工具支持复杂动效的实现。

四、应用场景与组件库

Material Design 提供了一套完整的组件库(Material Components),涵盖常用 UI 元素的设计规范与代码实现,开发者可直接调用。核心组件包括:

  • 导航:底部导航栏(Bottom Navigation)、抽屉菜单(Drawer)、顶部应用栏(AppBar)。
  • 输入与反馈:文本框(Text Field)、按钮(Button)、滑块(Slider)、对话框(Dialog)、Snackbar(轻量提示)。
  • 数据展示:卡片(Card)、列表(List)、网格(Grid)、表格(Table)、图表(Charts)。
  • 高级组件:底部表单(Bottom Sheets)、模态抽屉(Modal Drawer)、悬浮操作按钮(FAB)。

这些组件均遵循设计规范,支持跨平台适配(Android、iOS、Web),开发者可通过 Material Components 库(如 Android 的 Material Components for Android、Web 的 Material UI)快速集成。

Compose项目使用

Material Design 的官网也可以自己选取设计元素,作为一个压缩包下载下来,里面有Color,Style等文件,放到项目中就可以直接使用。

也可以一步步地自己配置每一个参数的色值,集成 ColorScheme 即可。内部可配置的参数非常多,根据名字也可以猜到其使用场景。

@Immutable
class ColorScheme(
    val primary: Color,
    val onPrimary: Color,
    val primaryContainer: Color,
    val onPrimaryContainer: Color,
    val inversePrimary: Color,
    val secondary: Color,
    val onSecondary: Color,
    val secondaryContainer: Color,
    val onSecondaryContainer: Color,
    val tertiary: Color,
    val onTertiary: Color,
    val tertiaryContainer: Color,
    val onTertiaryContainer: Color,
    val background: Color,
    val onBackground: Color,
    val surface: Color,
    val onSurface: Color,
    val surfaceVariant: Color,
    val onSurfaceVariant: Color,
    val surfaceTint: Color,
    val inverseSurface: Color,
    val inverseOnSurface: Color,
    val error: Color,
    val onError: Color,
    val errorContainer: Color,
    val onErrorContainer: Color,
    val outline: Color,
    val outlineVariant: Color,
    val scrim: Color,
    val surfaceBright: Color,
    val surfaceDim: Color,
    val surfaceContainer: Color,
    val surfaceContainerHigh: Color,
    val surfaceContainerHighest: Color,
    val surfaceContainerLow: Color,
    val surfaceContainerLowest: Color,
)

ColorScheme 类定义了 MaterialTheme 里所有命名颜色参数,这些参数在设计应用界面时,用于确保颜色和谐、文字可读,并且能区分不同的 UI 元素和表面。

主要颜色相关

  • primary:主色,在应用的屏幕和组件里出现频率最高的颜色。
  • onPrimary:主色上显示的文字和图标的颜色。
  • primaryContainer:容器首选的色调颜色。
  • onPrimaryContainer:显示在 primaryContainer 之上的内容颜色(及其状态变体)。
  • inversePrimary:在需要反色方案的地方作为“主色”使用的颜色,例如 SnackBar 上的按钮。

次要颜色相关

  • secondary:次要颜色,用于突出和区分产品,适用于浮动操作按钮、选择控件、高亮选中文本、链接和标题等。
  • onSecondary:次要颜色上显示的文字和图标的颜色。
  • secondaryContainer:用于容器的色调颜色。
  • onSecondaryContainer:显示在 secondaryContainer 之上的内容颜色(及其状态变体)。

第三颜色相关

  • tertiary:第三颜色,可用于平衡主色和次要颜色,或突出显示输入框等元素。
  • onTertiary:第三颜色上显示的文字和图标的颜色。
  • tertiaryContainer:用于容器的色调颜色。
  • onTertiaryContainer:显示在 tertiaryContainer 之上的内容颜色(及其状态变体)。

背景和表面颜色相关

  • background:可滚动内容后面显示的背景颜色。
  • onBackground:背景颜色上显示的文字和图标的颜色。
  • surface:影响组件表面(如卡片、工作表和菜单)的颜色。
  • onSurface:表面颜色上显示的文字和图标的颜色。
  • surfaceVariant:与 surface 用途类似的另一种颜色选项。
  • onSurfaceVariant:可用于 surface 之上内容的颜色(及其状态变体)。
  • surfaceTint:用于应用色调高程的组件,叠加在 surface 之上。高程越高,该颜色的使用比例越大。
  • inverseSurface:与 surface 形成强烈对比的颜色,适用于位于 surface 颜色表面之上的表面。
  • inverseOnSurface:与 inverseSurface 对比度良好的颜色,适用于位于 inverseSurface 容器之上的内容。

错误颜色相关

  • error:错误颜色,用于指示组件中的错误,例如文本字段中的无效文本。
  • onError:错误颜色上显示的文字和图标的颜色。
  • errorContainer:错误容器首选的色调颜色。
  • onErrorContainer:显示在 errorContainer 之上的内容颜色(及其状态变体)。

边框和遮罩颜色相关

  • outline:用于边界的微妙颜色,为了可访问性增加对比度。
  • outlineVariant:用于装饰元素边界的实用颜色,在不需要强对比度时使用。
  • scrim:遮挡内容的遮罩颜色。

表面变体颜色相关

  • surfaceBright:surface 的变体,无论在浅色还是深色模式下,始终比 surface 亮。
  • surfaceDim:surface 的变体,无论在浅色还是深色模式下,始终比 surface 暗。
  • surfaceContainer:影响组件容器(如卡片、工作表和菜单)的 surface 变体。
  • surfaceContainerHigh:比 surfaceContainer 强调程度更高的容器 surface 变体,用于需要更多强调的内容。
  • surfaceContainerHighest:比 surfaceContainerHigh 强调程度更高的容器 surface 变体,用于需要更多强调的内容。
  • surfaceContainerLow:比 surfaceContainer 强调程度更低的容器 surface 变体,用于需要较少强调的内容。
  • surfaceContainerLowest:比 surfaceContainerLow 强调程度更低的容器 surface 变体,用于需要较少强调的内容。

使用colorscheme

一般来说,如果只要求适配系统自带的深浅两色,可以直接使用 darkColorScheme()lightColorScheme() 这两个顶层方法创建即可,配置好其中需要的各个参数。

例如:

val DarkColorScheme = darkColorScheme(
    primary = Color(0xff484848),
    background = Color(0xFF010101),
    surface = Color(0xff303030),
    surfaceVariant = Color(0xff1d1d1d),
    onPrimary = Color(0xffffffff),
    secondary = Color(0xFF1a1a1a),
    tertiary = Color(0xff3d77c2),
    onSecondary = Color(0x99ffffff),
    error = Color(0x99e53c3c),
    errorContainer = Color(0xcce53c3c),
    onBackground = Color(0xff323232),
    onSurface = Color(0xff404040),
)

传到 MaterialTheme 可组合项内:

MaterialTheme(
   colorScheme = when (themeState.value) {
      ThemeState.DARK -> DarkColorScheme
      ThemeState.LIGHT -> LightColorScheme
      else -> if (isSystemInDarkTheme()) DarkColorScheme else LightColorScheme
   }
) {
   // ...
}

如果需要自定义颜色,或者需要适配更多的颜色,也可以使用 ColorScheme 类。

切换主题时,动态更改 MaterialTheme 的参数,触发其重组,内部的所有子可组合项就会跟随刷新自己的样式。

在Android 8 及以后,可以使用 WallpaperManager 来获取壁纸的主颜色,应用到自己APP的界面上,下面是一段简单的测试代码。(应用版本低,使用的不是Material3)

object WallPagerThemeManager {
    private lateinit var wallpaperManager: WallpaperManager
    private var wallpaperColors: WallpaperColors? = null

    var DynamicColorScheme = darkColors()

    fun init() {
        wallpaperManager = WallpaperManager.getInstance(appContext)
        wallpaperColors = wallpaperManager.getWallpaperColors(WallpaperManager.FLAG_SYSTEM)
        val primaryColor = wallpaperColors?.primaryColor?.toArgb()
        val secondaryColor = wallpaperColors?.secondaryColor?.toArgb()
        primaryColor?.let {
            DynamicColorScheme = darkColors(
                primary = Color(it),
                secondary = Color(secondaryColor ?: it),
            )
        }
    }
}

【Android进阶】Android各个版本的新增特性

【Android进阶】Android各个版本的新增特性

本文介绍了Android各个版本的新增特性,包括Android 5到Android 16

我入行的时候,手机端最新版本是Android 13,车机使用的是Android 11。

而今年2025年都出到16预览版了,看到掘金上有一个总结性的帖子,基于这一篇来扩展下,回顾下有哪些重点特性改动:

Android 各个版本的新增特性

Android 5.0 Lollipop (API 21 & 22)

Material Design

在设计语言上引入了Material Design,这是一种全新的视觉、运动和交互设计规范,旨在为用户提供直观且一致的体验。全新的用户界面,色彩鲜明,动画流畅,阴影和层级感更强。

Android Runtime (ART)

默认运行时从 Dalvik 切换到 ART (Android Runtime),带来更好的应用性能和更长的电池续航。ART 的新功能有:

  • 预先 (AOT) 编译
  • 改进的垃圾回收 (GC)
  • 改进的调试支持

通知改进

在 Android 5中,通知可以在锁屏时出现,同时来电等重要通知提醒会显示在浮动通知中,这是一个小浮动窗口,让用户无需离开当前应用即可响应或关闭通知。对于媒体播放和通知跳转等功能增加了 Notification.MediaStyle 模块。

“概览”屏幕

支持了显示多个来做同一个应用的activity。“最近使用的应用”屏幕,也称为“概览”屏幕,表示近期任务 或“最近用过的应用”屏幕,这是系统级界面,其中会列出 activity 和 tasks。 用户可以浏览列表、选择某个任务 恢复任务,或者通过滑开任务将其从列表中移除。

在 Android 5 中,我们可以通过 documentLaunchMode 属性来让最近屏幕显示多个来做同一个应用的activity。如上图所示,Google 云端硬盘应用的两个 Activity 显示在最近屏幕上,并且每个 Activity 展示不同的内容。

Android NDK 支持 64 位支持

Android 5.0 引入了对 64 位系统的支持。64 位增强功能可增加地址空间并提升性能,同时仍完全支持现有的 32 位应用。

OpenGL ES 3.1 的支持

Android 5.0 添加了 Java 接口和对 OpenGL ES 3.1 的原生支持。

引入了新的 Camera2 API

Android 5.0 引入了新的 android.hardware.camera2 API 来简化精细照片采集和图像处理。

多用户支持(平板电脑)

允许在同一设备上创建多个用户配置文件。还有访客模式,方便他人临时使用你的设备。

Android 6 Marshmallow (API 23)

运行时权限

应用不再在安装时请求所有权限,而是在需要时向用户动态请求。用户可以在运行时授予或拒绝权限。checkSelfPermission() 方法用来确定应用是否已获得权限; requestPermissions() 方法用于请求权限。即使应用并不以 Android 6.0(API 级别 23)为目标平台,我们也应该在新权限模式下测试应用。

休眠(Doze)模式和应用待机模式

在 Android 6.0 开始引入了两项省电功能,分别是休眠和应用待机。其中休眠是针对系统的,触发条件是设备静止、屏幕关闭、未连接电源;而应用待机则是针对应用的,触发条件是当用户有一段时间未触摸应用。

在休眠(Doze)模式下,系统会尝试通过限制应用对网络和 CPU 密集型服务的访问来节省电量。它还会阻止应用访问网络,并延迟其作业、同步和标准闹钟。

这两个措施可以优化电池续航,当设备长时间处于静止状态时,Doze 模式会降低后台活动;App Standby 则限制不常用应用的后台活动。

支持文本选择

当用户在应用中选择文本时,可以在浮动工具栏中显示文本选择操作。

指纹身份验证

Android 6 中引入了指纹认证API,提供标准化的指纹识别支持。该 api 的相关示例可以见 BiometricAuthentication

USB Type-C 支持

官方支持 USB Type-C 接口。

Google Now on Tap

长按 Home 键即可根据屏幕内容提供相关信息。

Android 7 Nougat (API 24 & 25)

Android 7.0 Nougat 提升了多任务处理能力和通知系统,并引入了 Vulkan API。

多窗口支持

在 Android 7中,引入了多窗口的支持,允许用户屏幕上同时弹出两个应用。(Multi-window)

增强了通知的功能

在 Android 7中重新设计了通知,让其变得更简单。更新功能有:

  • 直接回复,可以添加直接在通知中回复消息或输入其他文字的操作。
  • 系统可以将消息分组 (例如按消息主题),以及显示相应群组
  • 重新设置了通知模板样式

加强了休眠模式 Doze on the Go

在 Android 6中,需要设备处于静止状态才会进入休眠模式。而在 Android 7中,只要屏幕关闭一段时间且设备未接通电源,就会进入休眠模式,并对应用施加 CPU 和网络限制。 这意味着,即使用户随身携带设备,也可以节省电量 。

移除 CONNECTIVITY_ACTION、ACTION_NEW_PICTURE 和 ACTION_NEW_VIDEO 三个隐式广播

后台进程可能会耗费大量内存和电池电量。例如,某一隐式广播可能会启动许多已注册监听它的后台进程,即使这些进程可能并没有执行很多任务。这会对设备性能和用户体验产生重大影响。

因此在 Android 7 中移除了 CONNECTIVITY_ACTION(用于通知网络连接状态的变化)、ACTION_NEW_PICTURE(用于通知新图片的添加) 和 ACTION_NEW_VIDEO(用于通知新视频的添加) 这三个隐式广播。其中 CONNECTIVITY_ACTION 可以通过动态注册广播接收到,其他两个则静态和动态注册的都无法收到。

SurfaceView

在 Android 7中,对 SurfaceView 做了优化,让其电量的消耗更少。因此从 Android 7开始,建议使用 SurfaceView 而不是 TextureView。

Vulkan

Vulkan 是新一代的 3D 渲染库,提供了更高性能的3D图形渲染。在 Android 7 中我们可以使用它来代替 OpenGL ES。

Art 支持 AOT 和 JIT 混合编译

JIT 是一种动态编译技术,在应用运行时将字节码编译为机器码。AOT 是一种预先编译技术,在应用安装时将字节码(DEX 文件)直接编译为机器码

在 Android 5 以前,Dalvik 虚拟机使用 JIT。而在 Android 5 中,替换成了 ART 虚拟机,ART 虚拟机则依赖 AOT 编译。 在 Android 7 及其以后,支持 AOT 与 JIT 结合使用。即安装时部分代码进行 AOT 编译,加快启动速度。运行时 JIT 编译热点代码。设备空闲时,ART会根据 JIT 的热点代码进行 AOT 编译。

AOT 与 JIT 结合使用的最大好处是,加快了应用程序安装和系统更新的速度。比如之前大型应用在 Android 6中需要几分钟安装,而现在只需要几秒即可。

支持应用快捷方式

支持签名方案 v2

Android 7.0 引入了 APK Signature Scheme v2,这是一种新的应用签名方案, 可缩短应用安装时间,增强防范未经授权的行为 对 APK 文件的更改。

Android 8 Oreo (API 26 & 27)

Android 8 有两个版本,分别是 8.0 和 8.1,分别对应 API 26 和 API 27。Android 8.0 Oreo 专注于后台管理、通知系统和画中画模式。

画中画模式

允许应用在小窗口中继续播放视频,同时用户可以进行其他操作。Android 8.0 支持 activity 在画中画(PIP)模式中运行,主要应用于视频播放。

通知优化

Android 8.0 中对通知进行了重新设计,通知修改有:

  • 通知渠道:是一种将通知分类管理的机制。开发者可以为不同类型的通知创建不同的渠道,用户可以根据渠道单独管理通知的行为(如声音、振动、重要性等)。
  • 通知圆点:提醒用户有未读通知,提升通知的可见性。
  • 通知延后:通知延后允许用户将某个通知暂时隐藏,并在指定的时间后重新显示。
  • 通知超时:允许开发者设置通知的显示时间,超过时间后通知会自动消失。

在 Android 8.1 中,应用每秒只能发出一次通知提醒。如果一秒内有两次通知,那么只有第一次的通知会有提示音提醒一次,后面的会正常通知但是没有提示音提醒。

可下载字体

Android 8.0 允许开发者从供应商获取可下载字体资源,而无需将字体绑定到 APK 中。供应商和 Android 支持库负责下载字体,并将这些字体分享到各个 App 中。同样的操作也可用于获取表情资源,让应用不再止步于设备内置表情包。 由于国内手机厂商比较多,没有一个统一的Android手机生态(众所周知的原因,Google Play服务国内无法使用),所以必须自己搭建一套字体提供程序,因此比较麻烦。

自适应图标

自适应图标是 Android 8.0 引入的一项重要特性,其主要作用是:统一应用图标的外观,确保在不同设备上显示一致;支持动态效果,提升用户体验;提升主屏幕的视觉一致性。效果如下所示:

固定快捷方式

在 Android 8中可以把快捷方式固定在桌面上。

Neural Networks API

Android 8.1 推出了神经网络 API,具体可以看Neural Networks API

SharedMemory API

SharedMemory 是 Android 8.1 中新引入的 api,用于在进程之间共享内存。相对 MemoryFile ,SharedMemory 能更灵活地访问和控制共享内存区域。更多关于 SharedMemory ,可以看 Ashmem(Android共享内存)使用方法和原理

Bitmap内存的存放位置变更

在Android 8.0以前,图片的宽高数据和像素数据都保存在Java层。从Android 8.0开始,Java层只保存图片的宽高数据,图片的像素数据保存在Native层,不再占用Java Heap内存。

后台执行限制

Android 8.0 会限制后台应用可以执行的操作。应用在两个方面受到限制:

  • 后台服务限制:当应用处于空闲状态时,其对后台服务的使用受到限制。这不适用于对用户更明显的前台服务。
  • 广播限制:除了少数例外情况外,应用无法使用其清单注册隐式广播。它们仍然可以在运行时注册这些广播,并且可以使用清单注册显式广播和专门针对其应用的广播。

后台位置限制

为降低耗电量,Android 8.0 会对应用在后台运行时检索用户当前位置信息的频率进行限制。在这些情况下,应用每小时只能接收几次位置信息更新。

自动填充框架

方便用户快速填充表单信息。

Android 9.0 Pie (API 28)

室内定位

在 Android 9 中添加了 Wifi RTT 的支持,应用可以使用 RTT API 来实现室内定位的功能。

刘海屏支持

在 Android 9中提供了无边框屏幕、刘海屏的支持。我们可以提供 getDisplayCutout 来确定是否有刘海屏的存在。使用 DisplayCutout 类则可以让我们找出非功能区域的位置和形状。

通知增强

  • 增强即时通讯体验
  • 可以屏蔽渠道组
  • 新增广播类型

在 Android 9 之前,已暂停的应用发出的通知会被取消。 从 Android 9 开始,已暂停的应用发出的通知将一直隐藏,直到 应用恢复运行。

多摄像头支持

在 Android 9 中,支持同时访问多个摄像头。

针对非 SDK 接口的限制

从 Android 9(API 级别 28)开始,Android 平台对应用能使用的非 SDK 接口实施了限制。只要应用引用非 SDK 接口或尝试使用反射或 JNI 来获取其句柄,这些限制就适用。 更多信息可以看 针对非 SDK 接口的限制

签名方案v3

Android 9 增加了对 APK 签名方案 v3 的支持。v3支持密钥轮换,并增强了安全性。V3 签名方案与 V1 和 V2 完全兼容,即使设备不支持 V3,仍然可以使用 V1 或 V2 进行验证

旋转锁定

Android 9 增加了新的旋转锁定的旋转模式。

ImageDecoder

Android 9 引入了 ImageDecoder 类,它提供了一种现代化的图像解码方法。

改进了 PrecomputedText 类

提供了Magnifier,用于实现放大镜功能 Android 9 增强了 TextClassifier 类,该模型利用机器学习来识别所选文本中的某些实体, 提供操作建议。例如,TextClassifier 可让应用检测用户已选择电话号码。这样应用就可以让用户使用该号码拨打电话。

前台服务

在 Android 9中,前台服务需要申请 FOREGROUND_SERVICE 权限

隐私权变更

为了加强用户隐私保护,Android 9 引入了多项行为变更,例如限制后台应用对设备传感器的访问权限、限制从 Wi-Fi 扫描检索的信息,以及与通话、手机状态和 Wi-Fi 扫描相关的新权限规则和权限组。

电源管理

在 Android 9中,引入了应用待机分组机制,将应用根据用户的使用模式分为不同的优先级组(Buckets),每个组有不同的资源限制和唤醒策略。

同时 Android 9 对省电模式进行了改进,比如系统会更积极地将应用置于应用待机模式,而不是等待应用进入空闲状态。

应用待机分组包括:

  • 活跃(Active) :用户正在使用的应用。
  • 工作集(Working Set) :用户经常使用但当前未在前台的应用。
  • 常用(Frequent) :用户定期使用但不频繁的应用。
  • 罕见(Rare) :用户很少使用的应用。
  • 限制(Restricted) :长时间未使用且可能被限制后台活动的应用。

Android 10 (API 29)

可折叠设备的支持

在 Android 10 中,提供了对可折叠设备的支持。我们可以使用 Jetpack WindowManager 库为可折叠设备的窗口功能(如折叠边或合页)提供了一个 API surface,让应用具备折叠感知能力。 关于折叠屏的适配具体可见 了解可折叠设备

5G

Android 10 新增了针对 5G 的平台支持。可以使用 ConnectivityManager 来检测设备是否具有高带宽连接,还可以检查连接是否按流量计费。

深色主题

Android 10 新增了一个系统级的深色主题,非常适合光线较暗的场景并能帮助节省电量。

手势导航

Android 10 引入了全手势导航模式,该模式不显示通知栏区域,允许应用使用全屏来提供更丰富、更让人沉浸的体验。它通过边缘滑动(而不是可见的按钮)保留了用户熟悉的“返回”“主屏幕”和“最近用过”导航。

Thermal API

当设备过热时,会可能影响到 CPU 和 GPU 的运行。在 Android 10 中,应用和游戏可以使用 Thermal API 监控设备变化情况,并在设备过热时采取措施,使设备恢复到正常温度。

共享内存

以 Android 10 为目标平台的应用无法直接使用 ashmem (/dev/ashmem),而必须通过 NDK 的 ASharedMemory 类访问共享内存。

此外,应用无法直接对现有 ashmem 文件描述符进行 IOCTL,而必须改为使用 NDK 的 ASharedMemory 类或 Android Java API 创建共享内存区域。这项变更可以提高使用共享内存时的安全性和稳健性,从而提高 Android 的整体性能和安全性。

隐私权变更

在 Android 10 中对隐私权又做了一次变更,具体变更可以看 Android 10 中的隐私权

前台服务类型

Android 10 引入了 foregroundServiceType XML 清单属性,为前台服务定义对应的服务类型。比如 dataSync 是指从网络下载文件;mediaPlayback 是指播放音乐、有声读完等。

Android 11 (API 30)

隐私设置

Android 11 引入了一些变更和限制来加强用户隐私保护,其中包括:

  • 强制执行分区存储:对外部存储目录的访问仅限于应用专用目录,以及应用已创建的特定类型的媒体。
  • 自动重置权限:如果用户几个月未与应用互动,系统会自动重置应用的敏感权限。
  • 在后台访问位置信息的权限:用户必须转到系统设置,才能向应用授予在后台访问位置信息的权限。
  • 软件包可见性:当应用查询设备上已安装应用的列表时,系统会过滤返回的列表。

增加 APk 签名方案 v4

Android 11 添加了对 APK 签名方案 v4 的支持。注意 targetSdkVersion 为 Android 11 的应用不支持 v1 签名的应用,需要签名版本在 v2 及以上。

无线调试支持

Android 11 支持通过 Android 调试桥 (adb) 以无线方式部署和调试应用。

apk 增量安装

我们使用 adb install –incremental 可以支持 apk 增量更新。需要注意 apk 增量需要 v4 签名方案支持。

NDK Thermal API

native 版本的监控设备是否过热的 API,具体可以看 NDK Thermal

IME 新API

Android 11 引入了新的 API 以改进输入法 (IME) 的转换,例如屏幕键盘。这些 API 可让我们更轻松地调整应用内容,并与 IME 的出现和消失以及状态和导航栏等其他元素保持同步。

Frame rate API

Android 11 提供了一个 API,可让应用告知系统其预期帧速率,从而减少支持多个刷新率的设备上的抖动。

应用退出原因

Android 11 引入了 ActivityManager.getHistoricalProcessExitReasons() 方法,用于报告近期任何进程终止的原因。该方法可以用来收集崩溃诊断信息,例如进程终止是由于 ANR、内存问题还是其他原因所致。此外,我们还可以使用新的 setProcessStateSummary() 方法存储自定义状态信息,以便日后进行分析。

一次性权限

Android 11 允许用户授予应用一次性访问麦克风、摄像头或位置信息的权限。

ResourcesLoader 和 ResourcesProvider

在 Android 11(API 级别 30)中,ResourcesLoader 和 ResourcesProvider 是用于动态加载和管理资源的新 API。它们为开发者提供了更灵活的方式来加载和访问资源。

动态 intent 过滤器

Android 11 引入了 MIME 组,这是一个新的清单元素,可让应用在 intent 过滤器中声明一组动态的 MIME 类型,并在运行时以编程方式对其进行修改。

Android 12 (API 31)

Material You

全新的设计语言,允许系统根据壁纸颜色自动生成主题色,并应用于系统 UI 和支持的应用。

生命周期变更

在 Android 12 中,root activity 中按下了 back 按钮,不会 finish 当前的 activity。而是会将该 activity 放到后台。

自动更新应用

在 Android 12,增加了 PackageInstaller#setRequireUserAction() 方法,该方法可让安装程序应用执行应用更新而无需用户确认操作。

前台服务启动限制

当在后台运行时,不再允许应用启动前台服务。

应用启动画面 API

Android 12 引入了全新的应用启动画面 API ,可为所有应用启用可自定义的应用启动动画。

应用存储访问权限

现在,应用可以创建自定义 activity,让用户可以管理设备上的应用数据,并将此 activity 提供给文件管理器。具体可以看应用存储访问权限

游戏模式

Android 12 引入了一个新的游戏模式 ,可让用户优化游戏体验以提升性能或延长电池续航时间。

Android 13 (API 33)

ART 优化

在 Android 13(API 级别 33)及更高版本中,ART 可大大加快在原生代码之间切换的速度,JNI 调用速度现在最高可提高 2.5 倍。我们还重新设计了运行时引用处理,使其大部分都为非阻塞处理,从而进一步减少了卡顿。此外,我们还可以使用 Reference.refersTo() 公共 API 更快地回收无法访问的对象。

开发者可降级权限

从 Android 13 开始,应用可以撤消未使用的运行时权限。

APK 签名方案 v3.1

Android 13 可支持 APK 签名方案 v3.1,此方案在现有的 APK 签名方案 v3 的基础上进行了改进,此方案解决了 APK 签名方案 v3 的一些已知问题。

可编程的着色器

从 Android 13 开始,系统支持可编程 RuntimeShader 对象,其行为通过 Android 图形着色语言 (AGSL) 定义。AGSL 与 GLSL 共用大部分语法,但可用于 Android 渲染引擎中以自定义 Android 画布中的绘制行为以及过滤 View 内容。 Android 在内部使用这些着色器来实现涟漪效果、模糊和拉伸滚动。通过 Android 13 及更高版本,我们可以为应用创建类似的高级效果。

照片选择器

用户可以更精细地选择与应用共享的照片和视频,而不是授予整个媒体库的访问权限。

Android 14 (API 34)

限制最低可安装目标的 API 级别

在 Android 14,用户无法安装 targetSdkVersion 低于 23 的应用。

对隐式 intent 的限制

对于以 Android 14(API 级别 34)或更高版本为目标平台的应用,Android 会限制应用向内部应用组件发送隐式 intent。详情看对隐式 intent 和待处理 intent 的限制

应用只能终止自己的后台进程

从 Android 14 开始,当您的应用调用 killBackgroundProcesses() 时,该 API 只能终止自己应用的后台进程。如果我们传入其他应用的包名,此方法将不会对该应用的后台进程产生任何影响

必须提供前台服务类型

如果应用以 Android 14(API 级别 34)或更高版本为目标平台,则必须为应用中的每个前台服务指定至少一个前台服务类型 。

屏幕截图检测

如果用户在应用 activity 可见时截取屏幕截图,屏幕截图检测API 会调用回调并显示消息框消息。

Android 15 (API 35)

支持 16 KB 页面大小

从 Android 15 开始,Android 系统支持配置为使用 16 KB 页面大小的开发设备。详细信息可以看支持 16 KB 页面大小

私密空间

私密空间是 Android 15 中推出的一项新功能,可让用户在设备上创建一个单独的空间,在额外的身份验证层保护下,防止敏感应用遭到窥探。

最低可安装目标API级别

在 Android 15中,用户无法安装 targetSdkVersion 低于 24 的应用。

ApplicationStartInfo API

Android 15 上的 ApplicationStartInfo API 有助于深入了解应用启动,包括启动状态、在启动阶段所花的时间、在实例化 Application 类时应用的启动方式等。

详细的应用大小信息

Android 15 添加了 StorageStats.getAppBytesByDataType([type]) API,可让我们深入了解应用如何使用所有这些空间,包括 APK 文件分块、AOT 和加速相关代码、dex 元数据、库和引导式配置文件。详情可以看详细的应用大小信息

应用管理的性能分析

Android 15 包含 ProfilingManager 类,可让我们从应用中收集性能分析信息。详情可以看应用管理的性能分析

屏幕录制检测

Android 15 添加了屏幕录制检测 的支持,以检测是否正在录制应用。

Android 16

在Pixel设备上可以刷写最新的beta版本,从目前已经放出的消息来看,Android 16 有很多激动人心的新特性。

首先看看Android16的释放计划:

到25年第四季度会释放主要版本。

官方推介的新特性有如下几点:

相机和媒体 API 赋能创作者

Android 16 增强了对专业相机用户的支持,支持夜间模式场景检测、混合自动曝光和精确的色温调节。使用新的 Intent 操作捕捉动态照片比以往任何时候都更加轻松,并且我们正在持续改进 UltraHDR 图像,支持 HEIC 编码和 ISO 21496-1 草案标准中的新参数。对高级专业视频(APV) 编解码器的支持提升了 Android 在专业录制和后期制作工作流程中的地位,其感知无损的视频质量即使在多次解码/重新编码后也不会出现严重的视觉质量下降。

此外,Android 的照片选择器现在可以嵌入到您的视图层次结构中,用户将会喜欢搜索云媒体的功能。

更加一致、美观的应用程序

Android 16 引入了多项改进,旨在提升应用的一致性和视觉外观,为即将推出的Material 3 Expressive改进奠定基础。针对 Android 16 的应用将无法再选择关闭无边框显示,并且会忽略elegantTextHeight属性,以确保阿拉伯语、老挝语、缅甸语、泰米尔语、古吉拉特语、卡纳达语、马拉雅拉姆语、奥迪亚语、泰卢固语和泰语的间距合适。

自适应 Android 应用

随着 Android 应用如今可在各种设备上运行,以及大屏幕上更多窗口模式的出现,开发者应该构建能够适应任何屏幕和窗口尺寸(无论设备方向如何)的 Android 应用。对于以 Android 16(API 级别 36)为目标平台的应用,Android 16 改进了系统对方向、可调整大小和宽高比限制的管理方式。在最小宽度 >= 600dp 的屏幕上,这些限制将不再适用,应用将填满整个显示窗口。您应该检查您的应用,确保现有的界面能够无缝缩放,并在纵向和横向宽高比下正常运行。我们将提供框架、工具和库来提供帮助。

并排显示非自适应应用程序 UI,左侧显示文本“再见‘仅限移动’的应用程序”,右侧显示自适应应用程序 UI,文本“你好自适应应用程序”

您可以通过启用UNIVERSAL_RESIZABLE_BY_DEFAULT标志,在不使用应用兼容性框架的情况下测试这些替换。详细了解Android 16 中屏幕方向和可调整大小 API 的变更。

默认预测返回及更多

针对 Android 16 的应用将默认具有返回主屏幕、跨任务和跨活动的系统动画。此外,Android 16 将预测性返回导航扩展为三键导航,这意味着用户长按返回按钮后,在导航返回之前会看到上一屏幕的概览。

为了更轻松地获取返回主屏幕动画,Android 16 新增了对onBackInvokedCallback的支持,并添加了新的PRIORITY_SYSTEM_NAVIGATION_OBSERVER。Android 16 还添加了finishAndRemoveTaskCallback和moveTaskToBackCallback,用于通过预测返回实现自定义返回堆栈行为。

持续的进度通知

Android 16 引入了Notification.ProgressStyle ,它允许您创建以进度为中心的通知,这些通知可以使用点和段来指示用户旅程中的状态和里程碑。主要用例包括拼车、送货和导航。它是实时更新的基础,将在即将发布的 Android 16 更新中全面实现。

自定义 AGSL 图形效果

Android 16 添加了 RuntimeColorFilter 和 RuntimeXfermode,允许您在 AGSL 中创作诸如阈值、棕褐色和色相饱和度等复杂效果,并将它们应用于绘制调用。

帮助创建性能更好、更高效的应用程序和游戏 从帮助您了解应用性能的 API,到旨在提高效率的平台变更,Android 16 致力于确保您的应用拥有出色的性能。Android 16为ProfilingManager引入了系统触发的分析功能,确保在应用恢复有效生命周期后立即执行最多一次的ScheduleAtFixedRate执行,以提高效率;引入了hasArrSupport和getSuggestedFrameRate(int) 函数,使您的应用能够更轻松地利用自适应显示刷新率;并在SystemHealthManager中引入了getCpuHeadroom和getGpuHeadroom API 以及CpuHeadroomParams和GpuHeadroomParams函数,以便为游戏和资源密集型应用提供受支持设备上可用 GPU 和 CPU 资源的估算值。

JobScheduler 更新

Android 16 中的JobScheduler.getPendingJobReasons会返回作业待处理的多个原因,这些原因既包括您设置的显式约束,也包括系统设置的隐式约束。新的JobScheduler.getPendingJobReasonsHistory会返回最近待处理作业原因变更的列表,让您能够更好地调整应用在后台的运行方式。

Android 16 正在根据应用程序所在的应用程序待机存储桶、作业是否在应用程序处于顶部状态时开始执行以及作业是否在应用程序运行前台服务时执行来调整常规和加急作业运行时配额。

为了检测(然后减少)废弃作业,应用应使用系统为废弃作业分配的新STOP_REASON_TIMEOUT_ABANDONED作业停止原因,而不是STOP_REASON_TIMEOUT。

16KB 页面大小

Android 15 引入了对 16KB 页面大小的支持,以提升应用启动、系统启动和相机启动的性能,同时降低电池消耗。Android 16 新增了16 KB 页面大小兼容模式,结合新的Google Play 技术要求,使 Android 设备更接近于搭载这一重要变更。您可以使用最新版 Android Studio 中的16KB 页面大小检查和 APK 分析器来验证您的应用是否需要更新。

ART 内部变化

Android 16 包含 Android 运行时 (ART) 的最新更新,这些更新提升了 Android 运行时 (ART) 的性能并支持更多语言功能。这些改进也可通过 Google Play 系统更新应用于搭载 Android 12(API 级别 31)及更高版本的超过十亿台设备。依赖于内部非 SDK ART 结构的应用和库可能无法继续正常运行这些变更。

隐私和安全

Android 16 延续了我们提升安全性和保障用户隐私的使命。它改进了针对 Intent 重定向攻击的安全性,使MediaStore.getVersion在每个应用上都具有唯一性,添加了允许应用共享Android Keystore密钥的 API,整合了Android 隐私沙盒的最新版本,在配套设备配对流程中引入了新的行为以保护用户的位置隐私,并允许用户在照片选择器中轻松选择并限制对应用拥有的共享媒体的访问。

本地网络权限测试

Android 16 允许您的应用测试即将推出的本地网络权限功能,该功能需要您的应用获得 NEARBY_WIFI_DEVICES 权限。此变更将在未来的 Android 主要版本中强制执行。

为每个人打造的 Android

Android 16 添加了一些功能,例如与兼容 LE Audio 助听器的Auracast 广播音频、辅助功能更改(例如使用TYPE_DURATION扩展TtsSpan )、 AccessibilityNodeInfo中基于列表的新 API 、使用setExpandedState改进对可扩展元素的支持、 用于不确定ProgressBar小部件的RANGE_TYPE_INDETERMINATE、 支持“部分检查”状态的AccessibilityNodeInfo getChecked和setChecked(int)方法、 setSupplementalDescription (以便您可以为ViewGroup提供文本而无需覆盖其子级的信息)以及setFieldRequired(以便应用程序可以告知辅助功能服务需要输入表单字段)。

轮廓文本以实现最大文本对比度

Android 16 引入了轮廓文本,取代了高对比度文本,它在文本周围绘制更大的对比区域,从而大大提高了可读性,同时还引入了新的AccessibilityManager API,允许您的应用检查或注册监听器以查看此模式是否已启用。

以下还有一些对于开发者需要关注的点

ProfilingManager

在 15 的时候 Android 添加了 ProfilingManager,让应用能够使用 Perfetto 请求收集性能分析数据,例如启动或 ANR 等情况,而从 Android 16 开始,ProfilingManager 现在提供了系统触发的分析。 现在 App 可以使用 ProfilingManager#addProfilingTriggers() 来注册接收需要的信息,包括用于基于 Activity 的冷启动的 onFullyDrawn 和 ANR。

val anrTrigger = ProfilingTrigger.Builder(
                ProfilingTrigger.TRIGGER_TYPE_ANR
            )
                .setRateLimitingPeriodHours(1)
                .build()

val startupTrigger: ProfilingTrigger =  //...

mProfilingManager.addProfilingTriggers(listOf(anrTrigger, startupTrigger))

ApplicationStartInfo

同样,在 15 中 Android 添加的 ApplicationStartInfo,用于支持应用查看进程启动的原因、启动类型、开始时间、限制和其他有用的诊断数据。

而 Android 16 添加了 getStartComponent 来区分触发启动的组件类型,从而帮助开发者优化应用的启动流程。

触觉反馈

从 Android 11 开始,系统就增加了对更复杂的触觉效果的支持,更高级的 actuators 可以通过设备定义的语义基元的 VibrationEffect.Compositions 来支持这些效果。 而 Android 16 添加了新的 haptic API ,可以让应用定义触感反馈效果的振幅和频率曲线,同时抽象出设备功能之间的差异:

VibratorEnvelopeEffectInfo :提供有关振动器硬件功能和限制的信息,如支持的最大控制点数、单个区段的最小和最大持续时间、最大总持续时间等 VibratorFrequencyProfile:描述不同振动频率下 Vibrator 的输出。

这些新 API 消除了 App 在开发时需要对设备特定功能的 if 判断,开发人员可以直接使用 API 去创建自定义触觉反馈效果。

JobScheduler

Android 16 还引入了 JobScheduler#getPendingJobReasons(int jobId),它可以返回 Job 待处理的多个原因,比如开发者设置了显式约束条件和系统设置了隐式约束条件:

关于 Job Android 16 还引入了 JobScheduler#getPendingJobReasonsHistory(int jobId),从而支持返回最近约束更改的列表:

对于给定的 jobId,返回任务可能一直等待执行的原因的有限历史视图,返回的列表由 PendingJobReasonsInfo 组成,每个对象都包含一个自 epoch 以来的时间戳以及一个 ERROR(PendingJobReason constants/android.app.job.JobScheduler.PendingJobReason PendingJobReason constants) 表示

这些 API 调整,可以帮助开发者在调试 Job 时分析无法执行的原因,尤其是在看到某些任务的成功率降低或 Job 完成出现延迟问题时,可以更好地了解到某些 Job 是由于系统定义的约束还是由于显式设置的约束而未完成。

另外,从 Android 16 开始,Job 的执行会被优化调整,例如:

  • 在应用对用户可见时启动,并在应用不可见后继续的 Job 将遵守 Job runtime 配额
  • 与前台服务同时执行的 Job 将遵守 Job runtime 配额

配额(Quotas):简单来说,就是系统必须将执行时间分配给加急 Job,而执行时间不是无限的,相反每个应用都会收到一个执行时间配额,当应用使用其执行时间并达到其分配的配额时,在配额刷新之前,App 会无法再执行加速工作,这是 Android 有效地平衡应用 App 之间的资源策略,而限制前台执行时间的系统级配额决定了加急 Job 是否可以启动。

简单说就是,Android 16 会根据不同场景来调整常规和加速 Job 执行运行时配额,该优化调整会影响 WorkManager、JobScheduler 和 DownloadManager 调度的任务,要调试 Job 停止的原因,可以通过调用 WorkInfo.getStopReason 或则 JobParameters.getStopReason 来记录 Job 停止的原因。

最后,Android 16 完全弃用 JobInfo#setImportantWhileForeground ,这个人方法自 Android 12 (API 31) 开始已经弃用,从 Android 16 开始它将不再有效运行,并且 JobInfo#isImportantWhileForeground 方法从 Android 16 开始也将返回 false。

自适应刷新

Android 15 中引入的自适应刷新率 Adaptive refresh rate (ARR) ,从而支持硬件上的显示刷新率能够使用离散的 VSync 步骤适应内容帧速率,从而降低了功耗,同时消除了可能引起卡顿的模式切换。

但是尽管 Android 15 增加了对自适应刷新率的平台级支持,但它并没有为应用提供实际利用它的方法,而在 Android 16 DP2 在恢复 getSupportedRefreshRates() 的同时引入了 hasArrSupport() 和 getSuggestedFrameRate(int) ,从而让 App 可以更轻松地适配 ARR。

之前 getSupportedRefreshRates 在 **API level 23 的时候被 deprecated ** ,改成了 getSupportedModes ,现在它又复活了。

通过 getSuggestedFrameRate(int) 可以在给定描述性帧速率类别的情况下,获取显示器定义的帧速率:

float desiredMinRate = display.getSuggestedFrameRate(FRAME_RATE_CATEGORY_NORMAL);
  Surface.FrameRateParams params = new Surface.FrameRateParams.Builder().
                                      setDesiredRateRange(desiredMinRate, Float.MAX).build();
  surface.setFrameRate(params);

在不需要快速渲染速率的动画可以使用 FRAME_RATE_CATEGORY_NORMAL 来获取建议帧速率, 然后建议的帧速率可以在 Surface.FrameRateParams.Builder.setDesiredRateRange 设置。

同时 RecyclerView 1.4 页在内部支持了 ARR(例如 fling 或者 smooth scroll 下),并且未来 ARR 支持会添加到更多 Jetpack 库中。

Photo picker

Photo picker 是 Android 为用户提供了一种安全的媒体选择内置方式,它可以授予应用访问本地和云存储中所选图像和视频的权限,而不是让 App 访问到整个媒体库。 通过 Google 系统更新和 Google Play 服务组合使用模块化系统组件,它可支持到 Android 4.4(API 级别 19)。 而 Android 16 本次更新的预览版包含了新的 API,支持从云媒体提供商搜索 Android 照片选择器,照片选择器中的搜索功能未来也将适配推出。

Wifi 测距

在 Android 15 版本就增加了 Wi-Fi 测距的支持,这是一种可实现精确室内位置跟踪的定位技术,Wi-Fi 测距允许 <1m 精度,使其比使用信号强度测量的传统基于 Wi-Fi 的位置跟踪精确得多。

Wi-Fi 测距基于飞行时间而不是信号强度,因此更加精确。

而 Android 16 在采用 WiFi 6 的 802.11az 的受支持设备上,增加了对 WiFi 位置功能的安全功能支持,主要是应用能够将协议的更高精度、更强的可扩展性和动态调度与安全增强功能相结合,包括基于 AES-256 的加密和防止 MITM 攻击,例如通过 802.11az 与 Wi-Fi 6 标准集成解锁笔记本电脑或车门。

Predictive back

尽管 Android 15 默认启用系统预测性返回动画,但应用是否支持这些动画仍取决于应用本身。

而为了帮助 App 支持这些 API,Android 16 添加了新的 API,可帮助开发者在手势导航中启用预测性返回系统动画,例如返回主页动画,通过向新的 PRIORITY_SYSTEM_NAVIGATION_OBSERVER , App 可以在系统处理返回导航时接收常规的 onBackInvoked 调用,而不会影响正常的返回导航流程。

Android 16 还添加了 finishAndRemoveTaskCallbackmoveTaskToBackCallback ,通过使用 OnBackInvokedDispatcher 注册这些回调,系统可以在调用返回手势时触发特定行为并播放相应的提前动画。

【Android进阶】JNI调用是如何运行的

【Android进阶】JNI调用是如何运行的

本文介绍了JNI调用的运行流程

去年学习了整个Android平台的JNI实现流程以及基础类型,引用,多线程,核心指针和JavaVM的相关知识。

Android JNI开发

现在从架构层面,了解一下底层的运行,调用链路。

主要分析目标是 Android 平台。先简单回顾一下 JNI 的开发流程和动态库的生成流程。

首先,理解 JNI (Java Native Interface) 的核心作用至关重要。JNI 并不是一种编程语言,而是一套规范 (Specification)。它定义了:

  • JVM (Java Virtual Machine) 如何调用 Native 代码: 包括函数命名约定、参数传递、返回值处理等。
  • Native 代码如何与 JVM 交互: 比如创建 Java 对象、调用 Java 方法、访问 Java 字段、抛出 Java 异常等。
  • 数据类型映射: Java 类型(int, String, Object 等)与 C/C++ 类型(jint, jstring, jobject 等)之间的转换规则。

这套规范保证了不同 JVM 实现(如 Android 的 ART/Dalvik)和不同操作系统(Linux/Windows/macOS)上的 Java 代码都能以相同的方式与 Native 代码交互。

so 动态库生成

一个典型的 JNI 项目模组结构是下面这样的:

现在很多原生C++项目都采用CMake工具来构建编译系统,android平台开发 JNI 也是。CMake 本身并不是一个编译工具,而是一个跨平台的、开源的自动化构建系统。它不直接编译你的代码,而是根据你的 CMakeLists.txt 文件来生成特定平台和编译工具(如 Makefiles、Ninja 或 Visual Studio 项目文件)的构建脚本。

想象一下,如果你的项目需要在 Windows、Linux、macOS 甚至 Android 上编译,每种平台可能有不同的编译器和构建工具。手动为每种环境编写构建脚本会非常复杂且容易出错。CMake 就是为了解决这个问题而生,它提供了一套统一的语法来描述你的项目。

CMakeLists.txt

CMakeLists.txt 文件是什么呢?它是一个构建系统的配置文件。 形象地来概括,CMakeLists.txt 就是你告诉 CMake “我的项目长这样,你需要用这些源文件,链接这些库,编译成这种类型(可执行文件或库),并且用这些特殊的编译选项” 的说明书。它极大地简化了跨平台项目的构建管理。

它的主要作用包括 定义项目结构和属性管理依赖和库设置编译选项和宏查找外部包和组件配置和生成构建文件定义安装规则

例如:

# CMake 最低版本要求
cmake_minimum_required(VERSION 3.10.2)

# 定义项目名称
project("my_native_lib")

# 查找 Android Log 库
find_library(log-lib log)

# 添加一个共享库目标,命名为 "my_native_lib"
# 并指定它的源文件
add_library(                  # 添加一个库
              my_native_lib   # 库的名称
              SHARED          # 共享库
              src/main/cpp/native-lib.cpp ) # 源文件路径

# 链接日志库到你的目标库中
target_link_libraries(        # 指定需要链接的库
                       my_native_lib  # 你的目标库
                       ${log-lib} )   # Android Log 库

# 可选:设置 C++ 标准
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED TRUE)

# 可选:添加包含目录
target_include_directories(my_native_lib PUBLIC
                           src/main/cpp/include)

在这个例子中:

  • project() 定义了项目的名称。
  • find_library() 查找了 Android NDK 提供的 log 库。
  • add_library() 定义了一个名为 my_native_lib共享库,并指定了它的源文件 native-lib.cpp。这是在 Android 上生成 .so 文件的关键。
  • target_link_libraries()log-lib 链接到 my_native_lib 中,这样你的 C++ 代码就可以使用 __android_log_print 等函数了。
  • set(CMAKE_CXX_STANDARD 17) 设置了 C++ 编译标准为 C++17。

构建流程

动态库的构建流程本质上是 C++ 代码编译和链接的特定于 Android 平台的实现,主要依赖于 Android NDK (Native Development Kit)。NDK 是一套工具集,让你能够在 Android 平台上使用 C/C++ 等原生语言开发应用。它包含了下面这些组件:

  • Clang (LLVM): 用于编译 C/C++ 代码的编译器。
  • Linker (ld): 链接器,用于生成 .so 文件。
  • GCC (已逐渐被 Clang 替代): 另一种编译器。
  • Standard C/C++ Libraries:libc++gnustl (旧版本)。
  • Android Specific APIs: 比如 android/log.h(用于日志输出)和 jni.h(用于 JNI 接口)。
  • 构建系统: 目前主流都是使用 CMake

我们使用NDK来构建C++代码的目标是生成一个.so文件,这个文件可以在Android平台上被加载和调用。这个过程又分为哪些阶段?

C++ 代码编译

C++ 是一种编译型语言,这意味着你的源代码在执行之前必须经过一个或多个阶段的转换。这个过程通常包括以下几个主要步骤。

1. 预处理 (Preprocessing)

预处理器 (preprocessor) 会处理源代码中以 # 开头的指令,这些指令被称为预处理指令。常见的预处理操作包括:

  • 头文件包含 (#include <file>#include "file"): 将指定文件的内容插入到当前文件中。这就像把多个代码片段拼接起来。
  • 宏替换 (#define): 将宏定义替换为对应的文本。例如,#define PI 3.14159 会将代码中所有的 PI 替换为 3.14159
  • 条件编译 (#ifdef, #ifndef, #if, #else, #endif): 根据判断条件,选择性地编译或忽略代码块。这在针对不同平台或配置编译代码时非常有用。

预处理阶段的输出是一个纯 C++ 源文件,其中所有的预处理指令都已经被处理完毕,宏也已经展开,并且包含了所有引用的头文件内容。

2. 编译 (Compilation)

预处理后的源文件(通常以 .i.ii 结尾,但在实际开发中你可能很少直接看到)会被编译器 (compiler) 处理。编译器的主要任务是将 C++ 源代码翻译成汇编代码 (assembly code)

在这个阶段,编译器会执行以下工作:

  • 词法分析 (Lexical Analysis): 将源代码分解成一系列的词法单元 (tokens),如关键字、标识符、运算符、常量等。
  • 语法分析 (Syntax Analysis): 根据 C++ 语法规则,将词法单元组织成一个抽象语法树 (Abstract Syntax Tree - AST)。如果代码存在语法错误,编译器会在这里报错。
  • 语义分析 (Semantic Analysis): 检查代码的语义正确性,例如类型匹配、变量声明和初始化、函数调用是否正确等。
  • 中间代码生成 (Intermediate Code Generation): 将 AST 转换为一种更接近机器语言但仍然独立于具体机器的中间表示 (Intermediate Representation - IR),这有助于后续的优化。
  • 代码优化 (Code Optimization): 对中间代码进行各种优化,以提高程序的执行效率、减少代码大小。这可能包括死代码消除、常量折叠、循环优化等。
  • 目标代码生成 (Target Code Generation): 将优化后的中间代码转换成特定处理器架构的汇编代码

编译阶段的输出是一个或多个汇编文件(通常以 .s 结尾)。

3. 汇编 (Assembly)

汇编器 (assembler) 的作用是将汇编代码翻译成机器代码 (machine code),也就是由二进制指令组成的目标文件 (object file)。目标文件通常以 .o (Linux/macOS) 或 .obj (Windows) 结尾。

目标文件包含:

  • 编译后的机器指令。
  • 数据(全局变量、静态变量)。
  • 符号表:记录了程序中定义和引用的函数、变量等符号的信息。
  • 调试信息(如果开启了调试选项)。

此时的目标文件是独立的编译单元,它可能包含对其他文件或库中定义的函数和变量的引用(这些引用被称为未解析的符号)。

4. 链接 (Linking)

链接是整个编译过程的最后一个阶段,由链接器 (linker) 完成。链接器的主要任务是将一个或多个目标文件以及程序所需要的库文件组合在一起,生成一个可执行文件或一个共享库(如 .so 文件在 Android 上)。

链接器会完成以下工作:

  • 符号解析 (Symbol Resolution): 解决目标文件中所有未解析的符号引用。例如,如果你的代码调用了 printf 函数,链接器会在标准库中找到 printf 的实现,并将这个引用解析到实际的函数地址。
  • 重定位 (Relocation): 调整代码和数据段的地址。因为每个目标文件都是独立编译的,它们内部的地址都是相对地址。链接器会将它们合并到一个统一的地址空间中,并修正所有需要调整的地址。
  • 库链接 (Library Linking):
    • 静态链接 (Static Linking): 将所有需要的库代码直接复制到可执行文件中。优点是可执行文件独立,不依赖外部库;缺点是文件较大,且库更新时需要重新编译链接。
    • 动态链接 (Dynamic Linking) / 共享链接 (Shared Linking): 可执行文件只包含对共享库(如 .so 文件)的引用,而不是实际的代码。在程序运行时,操作系统的动态链接器/加载器会加载这些共享库。优点是可执行文件较小,多个程序可以共享同一个库实例,节省内存,且库更新时无需重新编译程序;缺点是依赖外部库,如果库缺失或版本不兼容,程序可能无法运行。Android NDK 开发中通常使用动态链接生成 .so 文件。

链接阶段的输出是一个可执行文件(如 Linux 上的 a.out 或 Windows 上的 .exe 文件)或者一个共享库(如 Linux/Android 上的 .so 文件,Windows 上的 .dll 文件)。

Gradle 构建过程

在 Android Studio 中,我们通常不需要手动调用 CMake 或 ndk-build。Android Gradle Plugin 会为你自动化这些任务。例如我们配置了:

android {
    // ...
    defaultConfig {
        // ...
        externalNativeBuild {
            cmake {
                // 指向 CMakeLists.txt 的路径
                    path file('src/main/cpp/CMakeLists.txt')
            }
            // 或者 ndkBuild { path file('src/main/cpp/Android.mk') }
        }
        ndk {
            abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86' // 指定需要构建的 ABI
        }
    }
    // ...
}

当我们 Sync 项目时,Gradle 会检查 externalNativeBuild 配置。当你点击 “Build” 或 “Run” 时,Gradle 会执行下面这些操作:

  • 根据 build.gradle 中配置的 ABI (abiFilters),为每个目标架构调用相应的 NDK 工具链。
  • 调用 CMake 或 ndk-build: 你的 CMakeLists.txtAndroid.mk 文件会被解析。
  • 编译 (Compilation): NDK 工具链中的 Clang 编译器将你的 C++ 源代码编译成对应架构的汇编代码,然后汇编成目标文件 (.o 文件)。
  • 链接 (Linking): NDK 工具链中的链接器将这些目标文件与 NDK 提供的系统库(如 liblog.so)和你的其他依赖库链接起来。最终,它会生成对应每个 ABI 的 .so 动态库文件(例如 libnative-lib.so)。
  • 打包到 APK: APK 文件本身是一个 ZIP 格式的归档文件。生成的 .so 文件会被 Gradle 打包到 APK 的特定目录下,通常是 lib/<ABI>/,例如 lib/arm64-v8a/libnative-lib.so

Java虚拟机如何运行的C++代码

JVM (或者说Android平台的Dalvik或者Art)本身并不能提供运行环境,直接 运行 C++ 代码。相反,它通过一套协调机制来将执行权交给操作系统和底层的 CPU,由它们来执行 C++ 代码。这个协调机制其实就是 JNI (Java Native Interface)

JNI 的桥梁作用

JVM 并不理解 C++ 代码,它只运行字节码(Java 代码编译后的中间产物)。当你调用了一个 native 方法时,JVM 知道这个方法不是由 Java 实现的,而是由外部的本地代码提供。

JNI 规范了 Java 类型和 C++ 类型之间的映射(例如 int 对应 jintString 对应 jstring),以及 Java 方法签名如何转换为 C++ 函数名。并且提供了一套 C 函数接口(通过 JNIEnv* 指针),允许 C++ 代码反过来操作 Java 对象、调用 Java 方法、访问 Java 字段等。

当你调用 System.loadLibrary("mylib"); 时,发生的事情远不止简单地把文件读进内存:

操作系统层面的加载

JVM 会请求操作系统(在 Android 上是 Linux 内核)的动态链接器 (Dynamic Linker) 来加载这个 .so 共享库文件。

动态链接器首先会在文件系统中找到 libmylib.so。然后将 .so 文件的内容 内存映射 (Memory Map) 到当前进程的虚拟地址空间。这并不是把整个文件一次性读入 RAM,而是建立一种映射关系,只有当程序真正访问到某个内存页时,才会从磁盘加载对应的页到物理内存。

映射完成后,执行解析符号。这是关键一步。.so 文件内部有一个符号表 (Symbol Table),记录了它所包含的函数(如 Java_com_example_MyClass_myNativeMethod)和变量的名称及其在文件内的相对地址。同时,它也记录了自身所依赖的外部函数(如 printf__android_log_print 等)的名称。动态链接器会查找这些外部依赖,在其他已经加载的系统库(如 libc.soliblog.so)中找到它们的实际内存地址,并更新 .so 库内部的重定位表 (Relocation Table),将对外部函数的引用替换为它们的实际地址。

最后,如果你的 .so 库中定义了 JNI_OnLoad 函数,动态链接器在加载并完成基本解析后,会通知 JVM。JVM 随后会调用这个 JNI_OnLoad 函数。你可以在这里执行动态注册,将 Java 方法直接映射到 C++ 函数的内存地址,或者进行其他初始化工作。

Java 方法与 Native 函数的绑定

在调用 native 方法之前,JVM 需要知道这个方法对应的 C++ 函数的具体入口点(也就是它在内存中的地址)。

  • 静态注册: 如果你没有使用 JNI_OnLoad 进行动态注册,那么当 JVM 第一次遇到一个 native 方法的调用时,它会:
    1. 根据 JNI 的命名规则(Java_包名_类名_方法名)构造一个字符串。
    2. 在已经加载的 .so 库的符号表中查找这个字符串对应的函数。
    3. 一旦找到,JVM 就会将这个 Java 方法与其对应的 C++ 函数的内存地址绑定 (Binding) 起来。这样,后续再次调用这个 Java 方法时,就可以直接跳转到那个 C++ 函数的地址,无需再次查找。
  • 动态注册: 如果你在 JNI_OnLoad 中使用了 RegisterNatives,那么绑定工作在库加载时就已经完成了。JVM 会直接获得 Native 函数的地址,效率更高,且不依赖于严格的命名约定。

JNI 调用过程

当 JVM 首次尝试调用一个 native 方法时,它需要知道这个 Java 方法对应的 Native 函数在 .so 库中的具体地址。这种寻址过程就是根据上文提到的符号表来查找。

静态注册时,JVM 会根据 JNI 的命名约定来查找 Native 函数。直接在已加载的 .so 库的符号表中查找符合命名规范的函数。这个查找过程在第一次调用该 Native 方法时发生,并将 Java 方法与 Native 函数的地址进行绑定 (Binding)。后续再次调用时,就可以直接跳转到 Native 函数的地址,提高了效率。如果找不到对应的函数,会抛出 UnsatisfiedLinkError

动态注册是通过 JNI_OnLoad 函数,具体的是 RegisterNatives JNI 函数手动将 Java 方法和 Native 函数进行关联。这种方式可以不遵循 JNI 的严格命名约定,Native 函数名可以更简洁。可以批量注册多个方法更高效。还有助于防止函数名过长导致某些旧系统(如 Windows)上的问题。

参数传递与栈帧

当 Java 代码调用 Native 方法时:

  1. 保存 Java 运行上下文: JVM 第一步会保存当前 Java 方法的执行上下文(如局部变量、操作数栈状态等)。
  2. JNIEnv 指针: JVM 会将一个 JNIEnv* 指针作为第一个参数传递给 Native 函数。这个指针提供了访问 JVM 功能的接口。
  3. JObject/JClass 参数:
    • 对于非静态 Native 方法,jobject 参数代表调用该方法的 Java 对象的引用。
    • 对于静态 Native 方法,jclass 参数代表调用该方法的 Java 类的引用。
  4. 参数转换: Java 基本类型(如 int, boolean)会直接映射到对应的 JNI 类型(jint, jboolean)。对于 Java 对象类型(如 String, Object),JNI 会传递一个 jobject 或其子类型的引用。这些引用是指向 JVM 内部 Java 对象的指针。
  5. 切换栈帧: JVM 的执行流会从 Java 栈帧切换到 Native 栈帧。CPU 会开始执行 Native .so 文件中的机器码。

Native 代码执行和返回

JVM 将执行流程跳转到 Native C++ 函数在内存中的起始地址。此时,CPU 开始执行 .so 库中的 C++ 机器码。JVM 不再直接“运行”C++ 代码,而是将控制权交给了 CPU,由 CPU 直接执行预编译好的机器指令。

Native C++ 代码在运行时,可以直接使用 JNIEnv 指针来与 JVM 进行交互。例如:

  • env->NewStringUTF(): 从 C 字符串创建 Java String 对象。
  • env->GetStringUTFChars(): 将 Java String 转换为 C 字符串。
  • env->CallIntMethod(): 调用 Java 对象的某个 int 类型返回值的成员方法。
  • env->ThrowNew(): 在 Java 层抛出异常。

当 Native 函数执行完毕并通过 return 语句返回结果时。Native 函数返回的 C/C++ 类型结果会被 JNI 自动转换回对应的 Java 类型

然后要恢复 Java 上下文,执行流会从 Native 栈帧切换回 Java 栈帧。如果 Native 代码中设置了待抛出的 Java 异常,JVM 会在返回后立即抛出该异常。如果无异常,Java 调用方会接收到 Native 方法的返回值,并继续其后续操作。

内存管理与垃圾回收

一个重要的底层细节是内存管理:

  • Java 堆: Java 对象存储在 Java 堆上,由 JVM 的垃圾回收器管理。
  • Native 堆: C/C++ 代码可以通过 malloc/freenew/delete 在 Native 堆上分配内存。这部分内存不受 JVM 垃圾回收器的管理,需要 Native 代码自己负责释放,否则会导致内存泄漏。
  • JNI 引用: 当 Native 代码获取到 Java 对象的引用时(jobject, jstring, jbyteArray 等),这些引用被称为 局部引用 (Local Reference)。它们在 Native 方法返回后会自动被 JVM 释放。如果你需要在 Native 方法返回后继续持有这些引用,你需要将它们提升为 全局引用 (Global Reference),并手动管理其生命周期。

【Android进阶】Android平台的文字转语音使用记录

【Android进阶】Android平台的文字转语音使用记录

本文介绍了在Android平台上使用TextSpeech实现文字转语音的使用记录。

背景

最近在做AI大模型对接的一些功能,调用完chat接口返回结果之后,发现豆包和Kimi等客户端都有语音播报功能,并且这些大厂经过一系列调优,可以实现很好听的音色和节奏停顿的效果。

那个人开发者可不可以在系统自带的免费语音助手的基础上做一个tts(Text To Speech)的播报呢?

调查发现Google已经有相关的接口了,并且尝试使用魅族20Pro手机成功实现了语音播报效果,记录一下使用过程。

TextSpeech

TextSpeech 是Android平台的文字转语音的接口,可以将文本合成为语音,可以支持立即播放,或者存储为音频文件。

初始化实例

创建实例需要传入两个参数,一个Context,一个连接的监听器,监听器会在初始化完成后回调。

    /**
     * The constructor for the TextToSpeech class, using the default TTS engine.
     * This will also initialize the associated TextToSpeech engine if it isn't already running.
     *
     * @param context
     *            The context this instance is running in.
     * @param listener
     *            The {@link TextToSpeech.OnInitListener} that will be called when the
     *            TextToSpeech engine has initialized. In a case of a failure the listener
     *            may be called immediately, before TextToSpeech instance is fully constructed.
     */
    public TextToSpeech(Context context, OnInitListener listener) {
        this(context, listener, null);
    }

播放停止与释放

就播放功能来说,使用起来非常简单,只需要创建一个TextSpeech对象,然后调用speak方法即可。

方法签名:

    public int speak(final CharSequence text,
                     final int queueMode,
                     final Bundle params,
                     final String utteranceId) {
        return runAction((ITextToSpeechService service) -> {
            Uri utteranceUri = mUtterances.get(text);
            if (utteranceUri != null) {
                return service.playAudio(getCallerIdentity(), utteranceUri, queueMode,
                        getParams(params), utteranceId);
            } else {
                return service.speak(getCallerIdentity(), text, queueMode, getParams(params),
                        utteranceId);
            }
        }, ERROR, "speak");
    }

参数说明: text:要转换的文本 queueMode:播放模式,有三种:QUEUE_ADDQUEUE_FLUSHQUEUE_MODE_DEFAULT params:参数,包括语音的语言、音调、语速等 utteranceId:唯一标识,用于区分不同的语音

停止时调用该对象的 stop() 方法,使用完毕退出时,需要调用 shutdown() 方法来释放引擎所使用的原生资源。我猜会这里会占用系统的多媒体编解码器连接,使用完需要及时释放防止其他app播放多媒体资源出错。

工具类完整代码

使用object实现单例,全局共享,在viewmodel里初始化,给界面提供接口。

object SpeechUtils {

    private lateinit var textToSpeech: TextToSpeech

    private const val TAG = "SpeechUtils"

    private const val TEST_IDENTIFIER = "test"

    private const val TEST_HELLO = "Hi, How are you? I'm fine. Thank you. And you?"

    private var isConnected = false

    val ttsConnectedListener = TextToSpeech.OnInitListener { status ->
        Log.d(TAG, "OnInitListener status: $status")
        isConnected = status == TextToSpeech.SUCCESS
    }

    fun init() {
        textToSpeech = TextToSpeech(appContext, ttsConnectedListener)
    }

    fun speak(text: String = TEST_HELLO, locale: Locale = Locale.US) {
        Log.d(TAG, "==========>speak<=========")
        if (isConnected) {
            textToSpeech.language = locale
            textToSpeech.speak(
                text,
                TextToSpeech.QUEUE_ADD,
                null,
                TEST_IDENTIFIER
            )
        } else {
            Log.d(TAG, "==========>TTS is not connected!<=========")
        }
    }

    fun stop() {
        textToSpeech.stop()
    }

    fun shutdown() {
        textToSpeech.shutdown()
    }
}

Viewmodel代码:

class MainStateHolder(
    private val retroService: RetroService,
    private val ktorClient: KtorClient,
) : ViewModel() {

    companion object {
        const val TAG = "MainStateHolder"
        const val TOKEN_KEY = "token"
        const val USER_NAME_KEY = "user_name"
    }

    init {
        SpeechUtils.init()
    }

    // ...

 
    fun speak(text: String, locale: Locale) {
        SpeechUtils.speak(text, locale)
    }
    
    fun stopSpeech(){
        SpeechUtils.stop()
    }

    override fun onCleared() {
        super.onCleared()
        ktorClient.release()
        SpeechUtils.shutdown()
    }
}

界面使用时,服务器返回值时调用播放,页面取消组合时,调用stop停止。同时,加入LifeCycle感知,在Activity退到后台,也调用停止接口:

@Composable
fun MyServerPage(
    mainStateHolder: MainStateHolder,
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onBackStack: () -> Unit,
) {
    BasePage("个人服务器测试", onCickBack = onBackStack) {

        LaunchedEffect(Unit) {
            mainStateHolder.getMyServerResponse()
        }

        val myResponse = mainStateHolder.myServerResponseStateFlow.collectAsState().value

        LaunchedEffect(myResponse) {
            if (myResponse.isNotEmpty()) {
                mainStateHolder.speak(myResponse, Locale.US)
            }
        }

        Box(
            modifier = Modifier.fillMaxSize(),
            contentAlignment = androidx.compose.ui.Alignment.Center
        ) {
            Text(text = myResponse)
        }

        DisposableEffect(lifecycleOwner) {
            Log.i("MyServerPage", "MyServerPage ${lifecycleOwner.lifecycle.currentState}")
            val observer = LifecycleEventObserver { _, event ->
                if (event == Lifecycle.Event.ON_STOP) {
                    // 当 Activity 退到后台时,Lifecycle 会触发 ON_STOP 事件
                    mainStateHolder.stopSpeech()
                }
            }
            lifecycleOwner.lifecycle.addObserver(observer)

            onDispose {
                mainStateHolder.stopSpeech()
                lifecycleOwner.lifecycle.removeObserver(observer)
            }
        }
    }
}

后续尝试使用付费版本的本地引擎,集成aar到本地进行调用,达到更好的播放效果。使用方式应该都是按照原生的接口设计。

Pagination