【Android进阶】APP冷启动流程解析

【Android进阶】APP冷启动流程解析

本文为launcher图标拉起app的全流程解析

从用户手指点击桌面上的应用图标到屏幕上显示出应用主Activity界面而完成应用启动,快的话往往都不需要一秒钟,但是这整个过程却是十分复杂的,其中涉及了Android系统的几乎所有核心知识点。

同时应用的启动速度也绝对是系统的核心用户体验指标之一,多少年来,无论是谷歌或是手机系统厂商们还是各个Android应用开发者,都在为实现应用打开速度更快一点的目标而不断努力。

概要图

cold_start

流程详解

事件传递到launcher图标view

手指按下后,硬件到驱动到系统侧链路暂且不看。

System部分

Android 系统是由事件驱动的,而 input 是最常见的事件之一,用户的点击、滑动、长按等操作,都属于 input 事件驱动,其中的核心就是 InputReaderInputDispatcherInputReaderInputDispatcher 是跑在 SystemServer进程中的两个 native 循环线程,负责读取和分发 Input 事件。

  • InputReader 负责从 EventHub 里面把 Input事件 读取出来,然后交给 InputDispatcher 进行事件分发
  • InputDispatcher 在拿到 InputReader 获取的事件之后,对事件进行包装后,寻找并分发到目标窗口;

system_server 的native线程 InputReader 读取到了一个触控事件。它会唤醒 InputDispatcher 去进行事件分发,先放入 InboundQueue 队列中,再去寻找处理事件的窗口,找到窗口后就会放入 OutboundQueue 队列,等待通过socket通信发送到 launcher应用 的窗口中,此时事件处于 WaitQueue 中,等待事件被处理,若5s内没有处理,就会向 systemserver 报ANR异常。

input_event

Launcher部分

Launcher进程接收到之后,通过 enqueueInputEvent 函数放入 “aq” 本地待处理队列中,唤醒 UI线程deliverInputEvent 流程进行事件分发处理,具体交给界面window里的类型来处理。

从View布局树的根节点DecorView开始遍历整个View树上的每一个子View或ViewGroup界面进行事件的分发、拦截、处理的逻辑。

这次的触摸事件被消耗后,Launcher及时调用 finishInputEvent 结束应用的处理逻辑,再通过JNI调用到native层InputConsumer的 sendFinishedSignal 函数通知 InputDispatcher 事件处理完成,及时从 waitqueue 里移除待处理事件,避免ANR异常。

整个处理流程是按照责任链的设计模式进行

Launcher到AMS的binder调用

上一轮 Input事件 传到图标view后,通过一个 ACTION_DOWNTouchEvent 触控事件和多个 ACTION_MOVE 事件,直到最后出现一个 ACTION_UPTouchEvent 事件后,去判断是 click 点击事件。

就开始通过 ActivityManager Binder 调用AMS的 startActivity 服务接口准备启动应用。

private int startActivityUnchecked(final ActivityRecord r, ActivityRecord sourceRecord,
                IVoiceInteractionSession voiceSession, IVoiceInteractor voiceInteractor,
                int startFlags, boolean doResume, ActivityOptions options, Task inTask,
                boolean restrictedBgActivity, NeededUriGrants intentGrants) {
        ...
        try {
            ...
            // 添加“startActivityInner”的systrace tag
            Trace.traceBegin(Trace.TRACE_TAG_WINDOW_MANAGER, "startActivityInner");
            // 执行startActivityInner启动应用的逻辑
            result = startActivityInner(r, sourceRecord, voiceSession, voiceInteractor,
                    startFlags, doResume, options, inTask, restrictedBgActivity, intentGrants);
        } finally {
            Trace.traceEnd(Trace.TRACE_TAG_WINDOW_MANAGER);
            ...
        }
        ...
    }

上面AMS的 startActivityUnchecked 函数,开始和结尾都会添加traceTAG记录时间,中间则是调用 startActivityInner 方法来启动应用。这个方法首 先检查当前Activity栈里处于resume状态的Activity ,如果当前 mResumedActivity 不是目标Activity,就通知这个Activity 进入Pause状态

/*frameworks/base/services/core/java/com/android/server/wm/ActivityStack.java*/
private boolean resumeTopActivityInnerLocked(ActivityRecord prev, ActivityOptions options) {
   ...
   // mResumedActivity不为null,说明当前存在处于resume状态的Activity且不是新需要启动的应用
   if (mResumedActivity != null) {
      // 执行startPausingLocked通知桌面应用进入paused状态
      pausing |= startPausingLocked(userLeaving, false /* uiSleeping */, next);
   }
   ...
}

final boolean startPausingLocked(boolean userLeaving, boolean uiSleeping,
            ActivityRecord resuming) {
    ...
    ActivityRecord prev = mResumedActivity;
    ...
    if (prev.attachedToProcess()) {
        try {
             ...
             // 相关执行动作封装事务,binder通知mResumedActivity也就是桌面执行pause动作
             mAtmService.getLifecycleManager().scheduleTransaction(prev.app.getThread(),
                        prev.appToken, PauseActivityItem.obtain(prev.finishing, userLeaving,
                        prev.configChangeFlags, pauseImmediately));
        } catch (Exception e) {
           ...
        }
     }
     ...
}

Launcher进程把其Activity的 pause 操作执行完毕后,执行 ActivityTaskManager.getService().activityPaused(token) 会将pause完成的结果通知到AMS。

AMS通知Launcher暂停自己的之后,会继续启动应用的逻辑,不等待Launcher进程的pause处理结果。

  • 先判断需要启动应用进程如果存在,调用 realStartActivityLocked
  • 如果进程不存在,就会先调用 startProcessAsync 创建进程。

拉起一个应用进程,具体是AMS通过Socket连接到Zygote进程,后者在开机时会创建好一个服务端。AMS通知Zygote进程去fork一个新进程,即 ZygoteProcess.start(...) 方法。

Zygote开机时就会创建 ZygoteServer 对象,调用 runSelectLoop 进入死循环等待AMS的请求。

/*frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java*/  
   @GuardedBy("this")
    final ProcessRecord startProcessLocked(...) {
        return mProcessList.startProcessLocked(...);
   }
   
   /*frameworks/base/services/core/java/com/android/server/am/ProcessList.java*/
   private Process.ProcessStartResult startProcess(HostingRecord hostingRecord, String entryPoint,
            ProcessRecord app, int uid, int[] gids, int runtimeFlags, int zygotePolicyFlags,
            int mountExternal, String seInfo, String requiredAbi, String instructionSet,
            String invokeWith, long startTime) {
        try {
            // 原生标识应用进程创建所加的systrace tag
            Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "Start proc: " +
                    app.processName);
            ...
            // 调用Process的start方法创建进程
            startResult = Process.start(...);
            ...
        } finally {
            Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
        }
    }
    
    /*frameworks/base/core/java/android/os/Process.java*/
    public static ProcessStartResult start(...) {
        // 调用ZygoteProcess的start函数
        return ZYGOTE_PROCESS.start(...);
    }
    
    /*frameworks/base/core/java/android/os/ZygoteProcess.java*/
    public final Process.ProcessStartResult start(...){
        try {
            return startViaZygote(...);
        } catch (ZygoteStartFailedEx ex) {
           ...
        }
    }
    
    private Process.ProcessStartResult startViaZygote(...){
        ArrayList<String> argsForZygote = new ArrayList<String>();
        ...
        return zygoteSendArgsAndGetResult(openZygoteSocketIfNeeded(abi), argsForZygote);
    }

ZygoteProcess#startViaZygote 函数中,拿到创建进程的参数,返回一个列表,里面含有pid等信息。

startProcess 中会封装相关进程信息请求参数,连接发送到zygote进程的socket服务端最后阻塞等待进程创建的结果。 startProcess 的阻塞工作线程,最终被711线程也就是zygote进程的主线程唤醒。

Zygote进程fork应用进程

fork_process

ZygoteServer 接收到请求后,去fork一个进程,fork采用 copy-on-write技术,这是linux创建进程的标准方法,调用一次,返回两次,返回值有3种类型,父进程里是新的子进程的pid,子进程返回的是0,为负数则表示出错了。

父进程去把pid通过socket发送到AMS,子进程通过调用 handleChildProc 函数,关闭父进程继承来的服务地址,再做一些通用的初始化工作,比如启用Binder机制,执行应用程序的入口函数。

子进程里有三个重要方法:

  1. 应用进程默认的java异常处理机制(可以实现监听、拦截应用进程所有的Java crash的逻辑);
  2. JNI调用启动进程的binder线程池(注意应用进程的binder线程池资源是自己创建的并非从zygote父进程继承的);
  3. 最后通过 RuntimeInit#applicationInit反射创建ActivityThread对象 并调用其“main”入口方法。进入到子进程内部逻辑。
/*frameworks/base/core/java/com/android/internal/os/ZygoteInit.java*/
public static Runnable zygoteInit(int targetSdkVersion, long[] disabledCompatChanges,
            String[] argv, ClassLoader classLoader) {
        ...
        // 原生添加名为“ZygoteInit ”的systrace tag以标识进程初始化流程
        Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "ZygoteInit");
        RuntimeInit.redirectLogStreams();
        // 1.RuntimeInit#commonInit中设置应用进程默认的java异常处理机制
        RuntimeInit.commonInit();
        // 2.ZygoteInit#nativeZygoteInit函数中JNI调用启动进程的binder线程池
        ZygoteInit.nativeZygoteInit();
        // 3.RuntimeInit#applicationInit中反射创建ActivityThread对象并调用其“main”入口方法
        return RuntimeInit.applicationInit(targetSdkVersion, disabledCompatChanges, argv,
                classLoader);
 }

目标APP的内部逻辑

建立消息机制

ActivityThread 对象的 main() 方法,里面主要分两步。

  • 一是创建主线程并 prepare() 来启动消息循环;
  • 二是通过binder调用AMS的 attachApplication 接口,将自己注册到AMS中。

继续分析主线程消息循环机制的建立, Looper.prepareMainLooper() ,通过prepare,创建MassageQueue队列,准备主线程的Looper,通过ThreadLocal机制实现与主线程的一对一绑定。

/*frameworks/base/core/java/android/app/ActivityThread.java*/
public static void main(String[] args) {
     ...
     // 1.创建Looper、MessageQueue
     Looper.prepareMainLooper();
     ...
     // 2.启动loop消息循环,开始准备接收消息
     Looper.loop();
     ...
}

// 3.创建主线程Handler对象
final H mH = new H();

class H extends Handler {
  ...
}

/*frameworks/base/core/java/android/os/Looper.java*/
public static void prepareMainLooper() {
     // 准备主线程的Looper
     prepare(false);
     synchronized (Looper.class) {
          if (sMainLooper != null) {
              throw new IllegalStateException("The main Looper has already been prepared.");
          }
          sMainLooper = myLooper();
     }
}

private static void prepare(boolean quitAllowed) {
      if (sThreadLocal.get() != null) {
          throw new RuntimeException("Only one Looper may be created per thread");
      }
      // 创建主线程的Looper对象,并通过ThreadLocal机制实现与主线程的一对一绑定
      sThreadLocal.set(new Looper(quitAllowed));
}

private Looper(boolean quitAllowed) {
      // 创建MessageQueue消息队列
      mQueue = new MessageQueue(quitAllowed);
      mThread = Thread.currentThread();
}

以上一切完成后,主线程就有了完整的 Looper、MessageQueue、Handler,此时 ActivityThread 的 Handler 就可以开始处理 Message。

主线程的初始化完成后,主线程就进入阻塞状态,等待 Message,一旦有 Message 发过来,主线程就会被唤醒,处理 Message,处理完成之后,如果没有其他的 Message 需要处理,那么主线程就会进入休眠阻塞状态继续等待。

包括 Application、Activity、ContentProvider、Service、Broadcast 等组件的生命周期函数,都会以 Message 的形式,在主线程按照顺序处理。

Looper循环器,其loop方法开启后,不断地从MessageQueue中获取Message;MessageQueue 就是一个 Message 管理器,队列中是 Message,在没有 Message 的时候,MessageQueue借助Linux的ePoll机制,阻塞休眠等待,直到有Message进入队列将其唤醒。

Message 是传递消息的对象,其内部包含了要传递的内容,最常用的包括 what、arg、callback 等。

将自己的进程注册到AMS

上面是应用内的消息机制建立和初始化,看看AMS怎么处理这个进程的 attach 注册请求的。AMS接收到请求后,通过 oneway类型 的binder调用此进程的 bindApplication 接口,里面会往主线程的消息队列中post一个 BIND_APPLICATION 的消息,触发主线程的 handleBindApplication

/*frameworks/base/core/java/android/app/ActivityThread.java*/
@UnsupportedAppUsage
private void handleBindApplication(AppBindData data) {
    ...
    // 1.创建应用的LoadedApk对象
    data.info = getPackageInfoNoCheck(data.appInfo, data.compatInfo);
    ...
    // 2.创建应用Application的Context、触发Art虚拟机加载应用APK的Dex文件到内存中,并加载应用APK的Resource资源
    final ContextImpl appContext = ContextImpl.createAppContext(this, data.info);
    ...
    // 3.调用LoadedApk的makeApplication函数,实现创建应用的Application对象
    app = data.info.makeApplication(data.restrictedBackupMode, null);
    ...
    // 4.执行应用Application#onCreate生命周期函数
    mInstrumentation.onCreate(data.instrumentationArgs);
    ...
}

这个方法里通过AMS发过来的 ApplicationInfo ,创建LoadedApk对象;创建 ApplicationContext ;触发Art虚拟机加载应用APK的Dex文件到内存中;通过 LoadedApk 加载应用的Resource资源;LoadedApkmakeApplication 方法创建 Application 对象;

// /frameworks/base/core/java/android/app/Instrumentation.java
public Application newApplication(ClassLoader cl, String className, Context context)
        throws InstantiationException, IllegalAccessException,
        ClassNotFoundException {
    Application app = getFactory(context.getPackageName())
            .instantiateApplication(cl, className);
    app.attach(context);
    return app;
}

然后执行 ApplicationattachBaseContext 方法,通过 installContentProviders 创建 ContentProvider ,执行其 onCreate 方法,随后执行 ApplicationonCreate 方法。

Dex文件加载

背景:Java代码在JVM被编译成字节码,再翻译成机器语言来运行。而DVM即Dalvik虚拟机不能和JVM一样能直接运行Java字节码,它只能运行.dex文件。dex文件是由Java的字节码通过Android的dx生成工具来生成的,这个过程就是打包apk的流程。

后面5.0后推出的ART虚拟机,相比Dalvik的JIT实时编译,是在启动时将dex转换成机器码,ART采用了AOT预先编译,在安装apk时就把dex文件转换成可以直接运行的oat文件,其可以支持多dex,大幅提升冷启动速度。缺点是安装速度变慢。

/*frameworks/base/core/java/android/app/ContextImpl.java*/
static ContextImpl createAppContext(ActivityThread mainThread, LoadedApk packageInfo,
            String opPackageName) {
    if (packageInfo == null) throw new IllegalArgumentException("packageInfo");
    // 1.创建应用Application的Context对象
    ContextImpl context = new ContextImpl(null, mainThread, packageInfo, null, null, null, null,
                0, null, opPackageName);
    // 2.触发加载APK的DEX文件和Resource资源
    context.setResources(packageInfo.getResources());
    context.mIsSystemOrSystemUiContext = isSystemOrSystemUI(context);
    return context;
}

/*frameworks/base/core/java/android/app/LoadedApk.java*/
@UnsupportedAppUsage
public Resources getResources() {
     if (mResources == null) {
         ...
         // 加载APK的Resource资源
         mResources = ResourcesManager.getInstance().getResources(null, mResDir,
                    splitPaths, mOverlayDirs, mApplicationInfo.sharedLibraryFiles,
                    Display.DEFAULT_DISPLAY, null, getCompatibilityInfo(),
                    getClassLoader()/*触发加载APK的DEX文件*/, null);
      }
      return mResources;
}

@UnsupportedAppUsage
public ClassLoader getClassLoader() {
     synchronized (this) {
         if (mClassLoader == null) {
             createOrUpdateClassLoaderLocked(null /*addedPaths*/);
          }
          return mClassLoader;
     }
}

private void createOrUpdateClassLoaderLocked(List<String> addedPaths) {
     ...
     if (mDefaultClassLoader == null) {
          ...
          // 创建默认的mDefaultClassLoader对象,触发art虚拟机加载dex文件
          mDefaultClassLoader = ApplicationLoaders.getDefault().getClassLoaderWithSharedLibraries(
                    zip, mApplicationInfo.targetSdkVersion, isBundledApp, librarySearchPath,
                    libraryPermittedPath, mBaseClassLoader,
                    mApplicationInfo.classLoaderName, sharedLibraries);
          ...
     }
     ...
     if (mClassLoader == null) {
         // 赋值给mClassLoader对象
         mClassLoader = mAppComponentFactory.instantiateClassLoader(mDefaultClassLoader,
                    new ApplicationInfo(mApplicationInfo));
     }
}

/*frameworks/base/core/java/android/app/ApplicationLoaders.java*/
ClassLoader getClassLoaderWithSharedLibraries(...) {
    // For normal usage the cache key used is the same as the zip path.
    return getClassLoader(zip, targetSdkVersion, isBundled, librarySearchPath,
                              libraryPermittedPath, parent, zip, classLoaderName, sharedLibraries);
}

private ClassLoader getClassLoader(String zip, ...) {
        ...
        synchronized (mLoaders) {
            ...
            if (parent == baseParent) {
                ...
                // 1.创建BootClassLoader加载系统框架类,并增加相应的systrace tag
                Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, zip);
                ClassLoader classloader = ClassLoaderFactory.createClassLoader(
                        zip,  librarySearchPath, libraryPermittedPath, parent,
                        targetSdkVersion, isBundled, classLoaderName, sharedLibraries);
                Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
                ...
                return classloader;
            }
            // 2.创建PathClassLoader加载应用APK的Dex类,并增加相应的systrace tag
            Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, zip);
            ClassLoader loader = ClassLoaderFactory.createClassLoader(
                    zip, null, parent, classLoaderName, sharedLibraries);
            Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
            return loader;
        }
}

/*frameworks/base/core/java/com/android/internal/os/ClassLoaderFactory.java*/
public static ClassLoader createClassLoader(...) {
        // 通过new的方式创建ClassLoader对象,最终会触发art虚拟机加载APK的dex文件
        ClassLoader[] arrayOfSharedLibraries = (sharedLibraries == null)
                ? null
                : sharedLibraries.toArray(new ClassLoader[sharedLibraries.size()]);
        if (isPathClassLoaderName(classloaderName)) {
            return new PathClassLoader(dexPath, librarySearchPath, parent, arrayOfSharedLibraries);
        }
        ...
}

上一轮的Context对象创建后,通过 packageInfo.getResources() 去加载加载APK的 Resource 资源赋给 context ,这个方法中需要 getClassLoader 获取类加载器,触发ART虚拟机加载dex文件。

资源文件加载

/*frameworks/base/core/java/android/app/LoadedApk.java*/
@UnsupportedAppUsage
public Resources getResources() {
     if (mResources == null) {
         ...
         // 加载APK的Resource资源
         mResources = ResourcesManager.getInstance().getResources(null, mResDir,
                    splitPaths, mOverlayDirs, mApplicationInfo.sharedLibraryFiles,
                    Display.DEFAULT_DISPLAY, null, getCompatibilityInfo(),
                    getClassLoader()/*触发加载APK的DEX文件*/, null);
      }
      return mResources;
}

/*frameworks/base/core/java/android/app/ResourcesManager.java*/
public @Nullable Resources getResources(...) {
      try {
          // 原生Resource资源加载的systrace tag
          Trace.traceBegin(Trace.TRACE_TAG_RESOURCES, "ResourcesManager#getResources");
          ...
          return createResources(activityToken, key, classLoader, assetsSupplier);
      } finally {
          Trace.traceEnd(Trace.TRACE_TAG_RESOURCES);
      }
}

private @Nullable Resources createResources(...) {
      synchronized (this) {
            ...
            // 执行创建Resources资源对象
            ResourcesImpl resourcesImpl = findOrCreateResourcesImplForKeyLocked(key, apkSupplier);
            if (resourcesImpl == null) {
                return null;
            }
            ...
     }
}

private @Nullable ResourcesImpl findOrCreateResourcesImplForKeyLocked(
            @NonNull ResourcesKey key, @Nullable ApkAssetsSupplier apkSupplier) {
      ...
      impl = createResourcesImpl(key, apkSupplier);
      ...
}

private @Nullable ResourcesImpl createResourcesImpl(@NonNull ResourcesKey key,
            @Nullable ApkAssetsSupplier apkSupplier) {
        ...
        // 创建AssetManager对象,真正实现的APK文件加载解析动作
        final AssetManager assets = createAssetManager(key, apkSupplier);
        ...
}

private @Nullable AssetManager createAssetManager(@NonNull final ResourcesKey key,
            @Nullable ApkAssetsSupplier apkSupplier) {
        ...
        for (int i = 0, n = apkKeys.size(); i < n; i++) {
            final ApkKey apkKey = apkKeys.get(i);
            try {
                // 通过loadApkAssets实现应用APK文件的加载
                builder.addApkAssets(
                        (apkSupplier != null) ? apkSupplier.load(apkKey) : loadApkAssets(apkKey));
            } catch (IOException e) {
                ...
            }
        }
        ...   
}

private @NonNull ApkAssets loadApkAssets(@NonNull final ApkKey key) throws IOException {
        ...
        if (key.overlay) {
            ...
        } else {
            // 通过ApkAssets从APK文件所在的路径去加载
            apkAssets = ApkAssets.loadFromPath(key.path,
                    key.sharedLib ? ApkAssets.PROPERTY_DYNAMIC : 0);
        }
        ...
    }

/*frameworks/base/core/java/android/content/res/ApkAssets.java*/
public static @NonNull ApkAssets loadFromPath(@NonNull String path, @PropertyFlags int flags)
            throws IOException {
        return new ApkAssets(FORMAT_APK, path, flags, null /* assets */);
}

private ApkAssets(@FormatType int format, @NonNull String path, @PropertyFlags int flags,
            @Nullable AssetsProvider assets) throws IOException {
        ...
        // 通过JNI调用Native层的系统system/lib/libandroidfw.so库中的相关C函数实现对APK文件压缩包的解析与加载
        mNativePtr = nativeLoad(format, path, flags, assets);
        ...
}

系统对于应用APK文件资源的加载过程其实就是创建应用进程中的 Resources 资源对象的过程,其中真正实现APK资源文件的I/O解析作,最终是借助于 AssetManager 中通过JNI调用系统Native层的相关C函数实现。

加载应用的 Resource 。上面 getResources() 方法里,创建 ResourcesImpl 时,会调用到 createAssetManager 方法, AssetManager 这是实际加载解析apk的类,通过路径去加载APK文件压缩包。

ApkAssets.loadFromPath(key.path, key.sharedLib ? ApkAssets.PROPERTY_DYNAMIC : 0)

通过JNI调用Native层的系统 system/lib/libandroidfw.so 库中的相关C函数实现对APK文件压缩包的解析与加载。

创建Activity

上面 AMS 接收到新进程的 Application 绑定请求之后,反馈其 bindApplication 接口后,立即开始执行启动Activity的流程。简要流程是框架 system_server 进程最终是通过 ActivityStackSupervisor#realStartActivityLocked 函数中,通过 LaunchActivityItemResumeActivityItem 两个类的封装,依次实现 binder调用 通知应用进程这边执行 Activity 的 Launch 和 Resume 动作。

/*frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java*/
@GuardedBy("this")
private boolean attachApplicationLocked(...) {
     ...
     if (app.isolatedEntryPoint != null) {
           ...
     } else if (instr2 != null) {
           // 1.通过oneway异步类型的binder调用应用进程ActivityThread#IApplicationThread#bindApplication接口
           thread.bindApplication(...);
     } else {
           thread.bindApplication(...);
     }
     ...
     // See if the top visible activity is waiting to run in this process...
     if (normalMode) {
          try {
            // 2.继续执行启动应用Activity的流程
            didSomething = mAtmInternal.attachApplication(app.getWindowProcessController());
          } catch (Exception e) {
                Slog.wtf(TAG, "Exception thrown launching activities in " + app, e);
                badApp = true;
          }
      }
}

/*frameworks/base/services/core/java/com/android/server/wm/ActivityTaskManagerService.java*/
public boolean attachApplication(WindowProcessController wpc) throws RemoteException {
       synchronized (mGlobalLockWithoutBoost) {
            if (Trace.isTagEnabled(TRACE_TAG_WINDOW_MANAGER)) {
                // 原生标识attachApplication过程的systrace tag
                Trace.traceBegin(TRACE_TAG_WINDOW_MANAGER, "attachApplication:" + wpc.mName);
            }
            try {
                return mRootWindowContainer.attachApplication(wpc);
            } finally {
                Trace.traceEnd(TRACE_TAG_WINDOW_MANAGER);
            }
       }
}

/*frameworks/base/services/core/java/com/android/server/wm/RootWindowContainer.java*/
boolean attachApplication(WindowProcessController app) throws RemoteException {
       ...
       final PooledFunction c = PooledLambda.obtainFunction(
                // startActivityForAttachedApplicationIfNeeded执行启动应用Activity流程
                RootWindowContainer::startActivityForAttachedApplicationIfNeeded, this,
                PooledLambda.__(ActivityRecord.class), app,
                rootTask.topRunningActivity());
       ...
}
 
private boolean startActivityForAttachedApplicationIfNeeded(ActivityRecord r,
            WindowProcessController app, ActivityRecord top) {
        ...
        try {
            // ActivityStackSupervisor的realStartActivityLocked真正实现启动应用Activity流程
            if (mStackSupervisor.realStartActivityLocked(r, app,
                    top == r && r.isFocusable() /*andResume*/, true /*checkConfig*/)) {
                ...
            }
        } catch (RemoteException e) {
            ..
        }
}

/*frameworks/base/services/core/java/com/android/server/wm/ActivityStackSupervisor.java*/
boolean realStartActivityLocked(ActivityRecord r, WindowProcessController proc,
            boolean andResume, boolean checkConfig) throws RemoteException {
         ...
        // 1.先通过LaunchActivityItem封装Binder通知应用进程执行Launch Activity动作       
         clientTransaction.addCallback(LaunchActivityItem.obtain(...));
         // Set desired final state.
         final ActivityLifecycleItem lifecycleItem;
         if (andResume) {
                // 2.再通过ResumeActivityItem封装Binder通知应用进程执行Launch Resume动作        
                lifecycleItem = ResumeActivityItem.obtain(dc.isNextTransitionForward());
         }
         ...
         clientTransaction.setLifecycleStateRequest(lifecycleItem);
         // 执行以上封装的Binder调用
         mService.getLifecycleManager().scheduleTransaction(clientTransaction);
         ...
}

主线程调用到 ActivityThreadhandleLaunchActivity 函数在主线程执行应用 ActivityLaunch 创建动作,这个方法里会执行 performLaunchActivity(r, customIntent) ,其中创建 ActivityContext ,通过反射创建 activity 对象,再调用其 attach 方法,创建 应用窗口PhoneWindow 对象,并配置 WindowManager 。然后通过 mInstrumentation.callActivityOnCreate(activity, r.state) 执行其 onCreate() 周期,在 setContentView 调用中创建DecorView对象。

Activity和窗口创建完成后, ActivityThread 调用 handleResumeActivity 来执行其 onResume() 流程,在 ActivityonResume() 周期回调之后,执行 makeVisible()

然后 WindowManager 执行 addView 动作,开启视图绘制逻辑,创建 ViewRootImpl 对象,并调用其 setView 方法。

/*frameworks/base/core/java/android/app/servertransaction/ResumeActivityItem.java*/
@Override
public void execute(ClientTransactionHandler client, IBinder token,
            PendingTransactionActions pendingActions) {
   // 原生标识Activity Resume的systrace tag
   Trace.traceBegin(TRACE_TAG_ACTIVITY_MANAGER, "activityResume");
   client.handleResumeActivity(token, true /* finalStateRequest */, mIsForward,
                "RESUME_ACTIVITY");
   Trace.traceEnd(TRACE_TAG_ACTIVITY_MANAGER);
}

/*frameworks/base/core/java/android/app/ActivityThread.java*/
 @Override
public void handleResumeActivity(...){
    ...
    // 1.执行performResumeActivity流程,执行应用Activity的onResume生命周期函数
    final ActivityClientRecord r = performResumeActivity(token, finalStateRequest, reason);
    ...
    if (r.window == null && !a.mFinished && willBeVisible) {
            ...
            if (a.mVisibleFromClient) {
                if (!a.mWindowAdded) {
                    ...
                    // 2.执行WindowManager#addView动作开启视图绘制逻辑
                    wm.addView(decor, l);
                } else {
                  ...
                }
            }
     }
    ...
}

public ActivityClientRecord performResumeActivity(...) {
    ...
    // 执行应用Activity的onResume生命周期函数
    r.activity.performResume(r.startsNotResumed, reason);
    ...
}

/*frameworks/base/core/java/android/view/WindowManagerGlobal.java*/
public void addView(...) {
     // 创建ViewRootImpl对象
     root = new ViewRootImpl(view.getContext(), display);
     ...
     try {
         // 执行ViewRootImpl的setView函数
         root.setView(view, wparams, panelParentView, userId);
     } catch (RuntimeException e) {
         ...
     } 
}

setView() 内部会开启硬件加速,调用 requestLayout 来触发界面绘制(measure、layout、draw)动作。通过 Binder 调用 WMSaddView 操作,注册应用窗口,创建 WindowInputEventReceiver 对象,传入本地创建 inputChannel 对象用于后续接收系统的触控事件。最后将 DocorViewparent 设置为自己,所以 ViewRootImpl 不是一个View,但它是所有 View 的顶层 Parent

/*frameworks/base/core/java/android/view/ViewRootImpl.java*/
public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView,
            int userId) {
      synchronized (this) {
         if (mView == null) {
             mView = view;
         }
         ...
         // 开启绘制硬件加速,初始化RenderThread渲染线程运行环境
         enableHardwareAcceleration(attrs);
         ...
         // 1.触发绘制动作
         requestLayout();
         ...
         inputChannel = new InputChannel();
         ...
         // 2.Binder调用访问系统窗口管理服务WMS接口,实现addWindow添加注册应用窗口的操作,并传入inputChannel用于接收触控事件
         res = mWindowSession.addToDisplayAsUser(mWindow, mSeq, mWindowAttributes,
                            getHostVisibility(), mDisplay.getDisplayId(), userId, mTmpFrame,
                            mAttachInfo.mContentInsets, mAttachInfo.mStableInsets,
                            mAttachInfo.mDisplayCutout, inputChannel,
                            mTempInsets, mTempControls);
         ...
         // 3.创建WindowInputEventReceiver对象,实现应用窗口接收触控事件
         mInputEventReceiver = new WindowInputEventReceiver(inputChannel,
                            Looper.myLooper());
         ...
         // 4.设置DecorView的mParent为ViewRootImpl
         view.assignParent(this);
         ...
      }
}

插入 Activity层级结构和相关概念

activity_window

  • Window是一个抽象类,通过控制DecorView提供了一些标准的UI方案,比如背景、标题、虚拟按键等,而 PhoneWindow 是Window的唯一实现类,在Activity创建后的attach流程中创建,应用启动显示的内容装载到其内部的 mDecor(DecorView)
  • DecorView 是整个界面布局View控件树的根节点,通过它可以遍历访问到整个View控件树上的任意节点;
  • WindowManager 是一个接口,继承自 ViewManager 接口,提供了View的基本操作方法; WindowManagerImp 实现了 WindowManager 接口,内部通过组合方式持有 WindowManagerGlobal,用来操作View; WindowManagerGlobal 是一个全局单例,内部可以通过 ViewRootImplView 添加至窗口中;
  • ViewRootImpl 是所有View的Parent,用来总体管理View的绘制以及与系统WMS窗口管理服务的IPC交互从而实现窗口的开辟; ViewRootImpl 是应用进程运转的发动机,可以看到 ViewRootImpl 内部包含 mView(DecorView)、mSurface、Choregrapher,mView 代表整个控件树,mSurfacce代表画布,应用的UI渲染会直接放到mSurface中,Choregorapher使得应用请求vsync信号,接收信号后开始渲染流程;

View绘制流程

我们的手机屏幕刷新频率有不同的类型,60Hz、120Hz等。60Hz表示屏幕在一秒内刷新60次,也就是每隔16.6ms刷新一次。屏幕会在每次刷新的时候发出一个 VSYNC 信号,通知CPU进行绘制计算。具体到我们的代码中,可以认为就是执行onMesure()onLayout()onDraw()这些方法。

同步栏删

requestLayout() 首先进行线程检查,然后给主线程 MessageQueue 队列里增加同步栏删,保证卡住同步消息,只让异步消息通过,直到 VSYNC 信号到来才会执行绘制任务并移除同步屏障。这样可以使绘制消息属于高优先级。

这样在等待 VSYNC 信号的时候主线程什么事都没干?是的。这样的好处是:保证在 VSYNC 信号到来之时,绘制任务可以被及时执行,不会造成界面卡顿。但这样也带来了相对应的代价:

  • 我们的同步消息最多可能被延迟一帧的时间,也就是16ms,才会被执行

  • 主线程Looper造成过大的压力,在VSYNC信号到来之时,才集中处理所有消息

改善这个问题办法就是:使用异步消息。当我们发送异步消息到MessageQueue中时,在等待VSYNC期间也可以执行我们的任务,让我们设置的任务可以更快得被执行且减少主线程Looper的压力。

可能有读者会觉得,异步消息机制本身就是为了避免界面卡顿,那我们直接使用异步消息,会不会有隐患?这里我们需要思考一下,什么情况的异步消息会造成界面卡顿:异步消息任务执行过长、异步消息海量。

如果异步消息执行时间太长,那不管异步还是同步任务,都会造成界面卡顿。

其次,若异步消息海量到达影响界面绘制,那么即使是同步任务,也是会导致界面卡顿的;

原因是MessageQueue是一个链表结构,海量的消息会导致遍历速度下降,也会影响异步消息的执行效率。所以我们应该注意的一点是:

不可在主线程执行重量级任务,无论异步还是同步。

最后建议的使用方案为,如果需要保证与绘制任务的顺序,使用同步Handler;其他,使用异步Handler。

继续看 requestLayout() 的源码:

/*frameworks/base/core/java/android/view/ViewRootImpl.java*/
public void requestLayout() {
    if (!mHandlingLayoutInLayoutRequest) {
         // 检查当前UI绘制操作是否发生在主线程,如果发生在子线程则会抛出异常
         checkThread();
         mLayoutRequested = true;
         // 触发绘制操作
         scheduleTraversals();
    }
}

@UnsupportedAppUsage
void scheduleTraversals() {
    if (!mTraversalScheduled) {
         ...
         // 注意此处会往主线程的MessageQueue消息队列中添加同步栏删,因为系统绘制消息属于异步消息,需要更高优先级的处理
         mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
         // 通过Choreographer往主线程消息队列添加CALLBACK_TRAVERSAL绘制类型的待执行消息,用于触发后续UI线程真正实现绘制动作
         mChoreographer.postCallback(
                    Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
         ...
     }
}

通过 mChoreographer.postCallback ,往主线程消息队列添加 CALLBACK_TRAVERSAL 绘制类型的待执行消息,用于触发后续UI线程真正实现绘制动作。

Choreographer背景

Choreographer,编舞者,配合系统的VSync垂直同步机制,每次VSync信号到来,就绘制一帧,给app的渲染提供一个稳定的Message处理时机。其在渲染链路中承上启下,统筹处理app的消息和回调,输入事件,动画,Traversal等,到下一次Vsync信号来的时候统一处理,对下他负责接收和请求VSync信号。ViewRootImpl推送待执行的消息之后,Choreographer向系统申请APP的VSync信号,等待信号到来之后,调用到 doTraversal 方法去执行真正的绘制操作。

Vysnc垂直同步

是Android在“黄油计划”中引入的一个重要机制,本质上是为了协调BufferQueue的应用生产者生成UI数据动作和SurfaceFlinger消费者的合成消费动作,避免出现画面撕裂的Tearing现象。Vysnc信号分为两种类型:

  • app类型的Vsync:app类型的Vysnc信号由上层应用中的Choreographer根据绘制需求进行注册和接收,用于控制应用UI绘制上帧的生产节奏。根据第7小结中的分析:应用在UI线程中调用invalidate刷新界面绘制时,需要先透过Choreographer向系统申请注册app类型的Vsync信号,待Vsync信号到来后,才能往主线程的消息队列放入待绘制任务进行真正UI的绘制动作;
  • sf类型的Vsync:sf类型的Vsync是用于控制SurfaceFlinger的合成消费节奏。应用完成界面的绘制渲染后,通过Binder调用queueBuffer接口将缓存数据返还给应用对应的BufferQueue时,会申请sf类型的Vsync,待SurfaceFlinger 在其UI线程中收到 Vsync 信号之后,便开始进行界面的合成操作。

doTraversal绘制操作

/*frameworks/base/core/java/android/view/ViewRootImpl.java*/
void doTraversal() {
     if (mTraversalScheduled) {
         mTraversalScheduled = false;
         // 调用removeSyncBarrier及时移除主线程MessageQueue中的Barrier同步栏删,以避免主线程发生“假死”
         mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
         ...
         // 执行具体的绘制任务
         performTraversals();
         ...
    }
}

private void performTraversals() {
     ...
     // 1.从DecorView根节点出发,遍历整个View控件树,完成整个View控件树的measure测量操作
     windowSizeMayChange |= measureHierarchy(...);
     ...
     if (mFirst...) {
    // 2.第一次执行traversals绘制任务时,Binder调用访问系统窗口管理服务WMS的relayoutWindow接口,实现WMS计算应用窗口尺寸并向系统surfaceflinger正式申请Surface“画布”操作
         relayoutResult = relayoutWindow(params, viewVisibility, insetsPending);
     }
     ...
     // 3.从DecorView根节点出发,遍历整个View控件树,完成整个View控件树的layout测量操作
     performLayout(lp, mWidth, mHeight);
     ...
     // 4.从DecorView根节点出发,遍历整个View控件树,完成整个View控件树的draw测量操作
     performDraw();
     ...
}

private int relayoutWindow(WindowManager.LayoutParams params, int viewVisibility,
            boolean insetsPending) throws RemoteException {
        ...
        // 通过Binder IPC访问系统WMS服务的relayout接口,申请Surface“画布”操作
        int relayoutResult = mWindowSession.relayout(mWindow, mSeq, params,
                (int) (mView.getMeasuredWidth() * appScale + 0.5f),
                (int) (mView.getMeasuredHeight() * appScale + 0.5f), viewVisibility,
                insetsPending ? WindowManagerGlobal.RELAYOUT_INSETS_PENDING : 0, frameNumber,
                mTmpFrame, mTmpRect, mTmpRect, mTmpRect, mPendingBackDropFrame,
                mPendingDisplayCutout, mPendingMergedConfiguration, mSurfaceControl, mTempInsets,
                mTempControls, mSurfaceSize, mBlastSurfaceControl);
        if (mSurfaceControl.isValid()) {
            if (!useBLAST()) {
                // 本地Surface对象获取指向远端分配的Surface的引用
                mSurface.copyFrom(mSurfaceControl);
            } else {
               ...
            }
        }
        ...
}

private void performMeasure(int childWidthMeasureSpec, int childHeightMeasureSpec) {
        ...
        // 原生标识View树的measure测量过程的trace tag
        Trace.traceBegin(Trace.TRACE_TAG_VIEW, "measure");
        try {
            // 从mView指向的View控件树的根节点DecorView出发,遍历访问整个View树,并完成整个布局View树的测量工作
            mView.measure(childWidthMeasureSpec, childHeightMeasureSpec);
        } finally {
            Trace.traceEnd(Trace.TRACE_TAG_VIEW);
        }
}

private void performDraw() {
     ...
     boolean canUseAsync = draw(fullRedrawNeeded);
     ...
}

private boolean draw(boolean fullRedrawNeeded) {
    ...
    if (mAttachInfo.mThreadedRenderer != null && mAttachInfo.mThreadedRenderer.isEnabled()) {
        ...
        // 如果开启并支持硬件绘制加速,则走硬件绘制的流程(从Android 4.+开始,默认情况下都是支持跟开启了硬件加速的)
        mAttachInfo.mThreadedRenderer.draw(mView, mAttachInfo, this);
    } else {
        // 否则走drawSoftware软件绘制的流程
        if (!drawSoftware(surface, mAttachInfo, xOffset, yOffset,
                        scalingRequired, dirty, surfaceInsets)) {
                    return false;
         }
    }
}

View绘制三大步,测量,布局,绘制。

首先需要移除同步栏删,removeSyncBarrier,避免主线程接受不了同步消息假死。

然后再执行具体绘制,从DecorView根节点遍历整个View树,完成measure操作。首次执行traversal操作时,通过Binder调用WMS的relayout接口,实现WMS计算窗口尺寸,向系统的surfaceflinger申请Surface画布操作,再由本地surface获取远端分配的surface的引用。画布有了准备进行layout布局,同样从DecorView根节点遍历,完成布局操作。最后的绘制如果开启了硬件加速,则走GPU硬件绘制,否则走CPU软件绘制。

绘制三大步

Measure 测量流程

在Measure测量的时候,会用到一个 MeasureSpec 类,这个类内部的一个32位的int值,其中高2位代表了 SpecMode ,低30位则代表 SpecSizeSpecMode 指的是测量模式, SpecSize 指的是测量大小。通过位运算来给这个常量的高2位赋值, SpecMode 有三种情况:

  • 00—UNSPECIFIED:未指定模式,View想多大就多大,父容器不做限制,一般用于系统内部的测量。
  • 11—- AT_MOST:最大模式,对应于 wrap_content` 属性,子View的最终大小是父View指定的SpecSize值,并且子View的大小不能大于这个值。
  • 01—–EXACTLY:精确模式,对应于 match_parent 属性和具体的数值,父容器测量出 View所需要的大小,也就是SpecSize的值。

每一个普通View都有一个MeasureSpec属性来对其进行测量。而对于DecorView来说,它的MeasureSpec由自身的LayoutParams和窗口的尺寸决定。

View 根据传入的 MeasureSpec 约束和自身的 layout_width/layout_height 属性,计算出自己理想的尺寸。最后必须调用 setMeasuredDimension(int measuredWidth, int measuredHeight) 来保存最终确定的测量结果。

performMeasure 这个方法里,会对一众的 子ViewGroup子View 进行测量。 View的onMeasure方法,实际是看 getDefaultSize() 来解析其宽高的,注意对于View基类来说,为了扩展性,它的两个MeasureSpec,AT_MOST和EXACTLY处理是一样的,即其宽高直接取决于所设置的specSize,所以自定义View直接继承于View的情况下,要想实现 wrap_content 属性,就需要重写onMeasure方法,自己设置一个默认宽高值。

ViewGroup的onMeasure

每个View的本身的onMeasure并不复杂,只需要关注好本身的尺寸就好了。

ViewGroup的Measure方法,它没有onMeasure,有一个 measureChildre() 方法:简单来说就是根据自身的 MeasureSpec和子元素的的 LayoutParams 属性来得出的子元素的 MeasureSpec 属性。它除了需要测量自己的宽与高之外,还需要逐个遍历子 view 以 measure 子 view。

如果 ViewGroup 自身是 EACTLY 的,那么 onMeasure 过程就会简单不少,因为它自身的宽与高是确定的,只需要挨个 measure 子View就可了,而且子View并不影响它本身。当然,要把 paddingmargin 考虑进来。

最为复杂的就是 AT_MOSTViewGroup 自身的宽与高是由其所有子View决定的,这才是最复杂的,也是各个ViewGroup子类布局器需要重点解决的,而且过程各不相同,因为每个布局器的特点不一样,所以过程并不相同,下面来各自讨论一下。

LinearLayout 的测量

它的方向只有两个,可以只分析一个方向,另外一个方向是差不多的,我们就看看垂直布局的measureVertical。

  • 当height mode是EXACTLY的时候,这个时候LinearLayout布局本身的高度是已知的,挨个遍历子view然后measure一下就可以。

  • 比较复杂的情况,是AT_MOST时,这其实也还好,理论上高度就是所有子view的高度之和。

  • 最为复杂的情况是处理weight,这需要很多复杂处理,要把剩余所有的空间按weight来分配,具体比较复杂,有兴趣的可以具体去看源码。这也说明了,为何在线性布局中使用weight会影响性能,代码中就可以看出当有weight要处理的时候,至少多遍历一遍子view以进行相关的计算。

虽然方向是 VERTICAL 时,重点只处理垂直方向,但是width也是需要计算的,但width的处理就要简单得多,如果其是EXACTLY的,那么就已知了;如果是AT_MOST的,就要找子view中width的最大值。

FrameLayout 的测量

其实是最简单的一个布局管理器,因为它对子view是没有约束的,无论水平方向还是垂直方向,对子view都是没有约束,所以它的measure过程最简单。

  • 如果是EXACTLY的,它本身的高度与宽度是确定的,那么就遍历子view,measure一下就可以了,最后再把margin和padding加一下就完事了。

  • 如果是AT_MOST的,那么也不难,遍历子View并measure,然后取子view中最大宽为它的宽度,取最大的高为其高度,再加上margin和padding,基本上就做完了。

因为,FrameLayout的measure过程最为简单,因此系统里很多地方默认用的就是FrameLayout,比如窗口里的root view。

RelativeLayout

这个是最为复杂的,从设计的目的来看,RelativeLayout要解决的问题也是提供了长与宽两个维度来约束子view。

总体过的过程就是要 分别从vertical方向和horizontal方向,来进行两遍的measure ,同时还要计算具体的坐标,实际上RelativeLayout的measure过程是把measure和layout一起做了。

Layout 布局流程

onLayout() 这是 ViewGroup 需要重写的核心方法,对于 View 不需要重写 onLayout()。ViewGroup中的 layout() 方法用来确定子元素的位置,View中的 layout() 方法则用来确定自身的位置。所以是ViewGroup来计算子View的参数,并调用子控件的layout方法。

layout() 方法接收四个参数:左边界 (l)、上边界 (t)、右边界 (r)、下边界 (b)。这些坐标都是相对于 父 View 的坐标系 而言的。

View的layout方法,其中分别传入 left,top,right,bottom 四个参数,表示其距离父布局的四个距离,再走到setFrame,最后到onLayout,这是一个空方法,由继承的类自己实现。

LinearLayout

依然是两个方向,因为LinearLayout的目的就是在某一个方向上对子view进行约束。看layoutVertical就可以了,水平方向上逻辑是一样的。

遍历一次子View即可,从父布局的left, top起始,考虑子view的height 以及上下的padding和margin,依次排列就可以了。需要注意的是,对于left的处理,理论上子view的left就应该等于父布局,因为这毕竟是vertical的,水平上是没有约束的,但是也要考虑Gravity,当然也要把padding和margin考虑进来。最后通过setChildFrame把排列好的坐标设置给子view。

总体来看,线性布局的layout过程比其measure过程要简单不少。

FrameLayout

FrameLayout对子view的排列其实是没有约束的,所以layout过程也不复杂,遍历子view,子view的left和top初始均为父布局,依据其Gravity来做一下排布即可,比如如果Gravity是right,那么子view就要从父布局的右侧开始计算,childRight=parentRight-margin-padding,childLeft=childRight-childWidth,以次类推,还是比较好理解的。

RelativeLayout

前面提到过RelativeLayout是在measure的时候就把坐标都计算好了,它的layout就是把坐标设置给子view,其余啥也没有。

热门八股问题:为什么onResume获取不到View的宽高,而View.post就可以拿到?
  1. onCreate和onResume中无法获取View的宽高,是因为还没执行View的绘制流程。
  2. view.post之所以能够拿到宽高,是因为在绘制之前,会将获取宽高的任务放到Handler的消息队列,等到View的绘制结束之后,便会执行。

Draw 绘制流程

触发逻辑

从上面的讨论中可以看出draw的触发逻辑有两条路:

一是,没有启用硬件加速时,走的软件draw流程,也是一条比较好理解的简单流程: performTraversal->performDraw->draw->drawSoftware->View#draw

二是,启用了硬件加速时,走的是 performTraversal->performDraw->draw->ThreadedRenderer#draw ,到这里就走进了硬件加速相关的逻辑了。

硬件加速的绘制流程

遍历 DecorView 树,递归调用每个子View节点的 updateDisplayListIfDirty 函数,最终完成绘制树的创建。再通过JNI调用到Native层的RenderThread渲染线程,并唤醒渲染线程利用OpenGL执行渲染任务。

软件绘制

ViewRootImpl 是直接调用根节点的 draw() 方法,那么这里便是整个view tree的入口。可先从 View#draw(canvas) 方法看起。

主要分为四步:

  1. 画背景 drawBackground()
  2. 画自己的内容通过onDraw来委派,具体的内容是在onDraw里面做的;
  3. 画子view,通过 dispatchDraw() 方法;
  4. 画其他的东西,如scroll bar或者focus highlight等。

可以重点关注一下这些操作的顺序,先画背景,然后画自己,然后画子view,最后画scroll bar和focus之类的东西。

重点来看看dispatchDraw方法,因为其他几个都相对非常好理解,这个方法主要要靠ViewGroup来实现,因为在View里面它是空的,节点自己只需要管自己就可以了,只有父节点才需要关注如何画子View。

ViewGroup#dispatchDraw

这个方法做一些准备工作,如把 padding 考虑进来并进行clip,后会遍历子View,针对 每个子view调用 drawChild 方法,这实际上就 是调用回了 View#draw(canvas,parent,drawingTime) 方法,注意这个方法是package scope的,也就是说只能供view框架内部调用。这个方法并没有做具体的渲染工作(因为每个View的具体渲染都是在onDraw里面做的),这个方法里面做了大量与动画相关的各种变换。

Canvas对象是从哪里来的

View的渲染过程其实大都是 GUI框架内部的逻辑流程控制 ,真正涉及graphics方面的具体的图形如何画出来,其实都是由Canvas对象来做的,比如如何画点,如何画线,如何画文字,如何画图片等等。

一个Canvas对象从ViewRootImpl传给View tree,就在view tree中一层一层的传递,每个view都把其想要展示的内容渲染到Canvas对象中去。

那么,这个Canvas对象又是从何而来的呢?从view tree的一些方法中可以看到,都是从外面传进来的,view tree的各个方法(draw, dipsatchDraw和drawChild)都只接收Canvas对象,但并不创建它。

从上面的逻辑可以看到Canvas对象有二个来源:

  • 一是在 ViewRootImpl 中创建的,当走软件渲染时,会用 Surface 创建出一个 Canvas 对象,然后传给view tree。从 ViewRootImpl 的代码来看,它本身就会持有一个 Surface 对象,大概的逻辑就是每一个Window对象内,都会有一个用来渲染的 Surface
  • 另外一个来源就是走硬件加速时,会由 hwui 创建出Canvas对象。

合成送显

这三大步走完之后,应用界面的内容用户依然还不可见,需要由 RenderThread 线程的渲染处理,渲染完成后,还需要通过Binder调用 “上帧” 交给 surfaceflinger 进程中进行合成后送显才能最终显示到屏幕上。

总结,应用在UI线程中从根节点DecorView出发,递归遍历每个子View节点,搜集其drawXXX绘制动作并转换成DisplayListOp命令,将其记录到DisplayListData并填充到RenderNode中,最终完成整个View绘制命令树的构建。从此UI线程的绘制任务就完成了。

C++层面的RenderThread 渲染流程

syncFrameState中遍历View树上每一个RenderNode,执行prepareTreeImpl函数,实现同步绘制命令树的操作;调用OpenGL库API使用GPU,按照构建好的绘制命令完成界面的渲染,将前面已经绘制渲染好的图形缓冲区Binder上帧给SurfaceFlinger合成和显示。

SurfaceFlinger作为系统中独立运行的一个Native进程,借用Android官网的描述,其为承上启下的角色,就是通过Surface与不同的应用进程建立联系,接收它们写入Surface中的绘制缓冲数据,对它们进行统一合成。然后对下层,通过屏幕的后缓存区与屏幕建立联系,发送合成好的数据到屏幕显示设备。

图形载体为Buffer,Surface为Buffer封装,管理了多个Buffer,内部是通过BufferQueue来管理的。这是一个生产者消费者模型, 应用进程为生产者,SurfaceFlinger为消费者 。应用进程开始界面渲染之前,通过Binder向 SurfaceFlinger 申请一张可用的buffer,使用CPU或者GPU渲染之后,将缓存数据返回给进程对应的 BufferQueue ,等其可用时申请sf类型的VSync信号,通知 SurfaceFlinger 去消费合成。 SurfaceFlinger 拿取buffer合成结束之后,再度将其置为free状态,返回对应 BufferQueue 中。

【Android进阶】JVM&DVM&ART虚拟机对比

【Android进阶】JVM&DVM&ART虚拟机对比

本文介绍了JVM虚拟机,Dalvik虚拟机还有ART虚拟机三者之间不同特点的对比

去年通读了深入理解Java虚拟机,对JVM的一系列特性有了系统的了解,然而作为Android开发,却还没有对Android平台特有的两代虚拟机做更为细致的学习,属实有点说不过去。在车机Android系统app的开发中,几乎涉及不到apk的动态安装卸载,一般是参与系统集成编译完就发布镜像,烧写后一直运行。所以对此专题,了解甚少。

JVM跨平台特性的原理是 字节码 ,字节码是一种中间代码,它是一种 面向虚拟机 的代码,而不是面向硬件的代码。每个JRE编译的时候针对每个平台编译,因此下载JRE(JVM、Java核心类库和支持文件)的时候是分平台的,JVM的作用是把平台无关的.class里面的字节码翻译成平台相关的机器码,来实现跨平台。

为何Android不直接使用JVM

从问题寻找答案,总是一个有效的学习方法,为什么Android平台当年不直接使用JVM作为硬件抽象之上的虚拟机供应用运行呢?

在早期(Android 4.4之前),Android使用的是Dalvik虚拟机,这是谷歌为安卓系统专门设计和开发的一款虚拟机,主要用于在资源受限的移动设备上高效运行应用程序。不直接使用JVM,主要有以下几个原因:

  • 资源限制: 早期的移动设备内存和存储空间都很有限。 JVM需要较大的内存和存储空间 ,不适合资源受限的移动设备。Dalvik虚拟机针对移动设备进行了优化,更加轻量级。
  • 性能考虑: JVM主要针对服务器和桌面环境优化,移动设备上,可能没有桌面和服务器上那么强大的性能,故JVM在移动设备上性能表现可能不佳。 Dalvik虚拟机采用了寄存器架构 ,相比 JVM的栈架构 在移动设备上有更好的性能表现。
  • 许可证问题: JVM受Oracle公司的许可证限制,Android为了避免潜在的法律风险,选择开发自己的虚拟机。这也是一个为人乐道的历史原因。
  • 定制化需求: Android需要一个 更加定制化的解决方案 来满足移动平台的特殊需求,如电源管理、内存管理等。Dalvik虚拟机可以更好地与Android系统集成。
  • 字节码格式: Dalvik使用的.dex格式比Java的.class文件更紧凑,更适合移动设备有限的存储空间。
  • 多进程架构: Android的应用运行在独立的进程中,每个进程有自己的Dalvik虚拟机实例,这种架构更适合移动操作系统的需求。
  • 安全性考虑: Dalvik虚拟机在安全性方面也有一定的优势,如对内存访问的控制和对异常的处理。

事实证明,Android使用Dalvik虚拟机的选择是正确的,如果直接使用JVM,在以上这些因素的影响下,Android系统可能真的发展不起来。

Dalvik虚拟机

JVM虚拟机里面,解释运行的是class文件,而在Dalvik虚拟机里面,解释运行的是dex(即“Dalvik Executable”)文件。

dex文件

回顾一下class文件的组成:

  • 魔数:用于标识文件类型,通常为0xCAFEBABE。
  • 版本号:用于标识class文件的版本号,包括主版本号和次版本号。
  • 常量池:用于存储常量,包括字符串、类名、字段名、方法名等。
  • 访问标志:用于标识类的访问权限,如public、final等。
  • 类信息:包括类名、父类名、接口名等。
  • 字段信息:包括字段名、字段类型、访问标志等。
  • 方法信息:包括方法名、方法类型、访问标志等。
  • 属性信息:用于存储类的附加信息,如注解、泛型信息等。

为了减小执行文件的体积,安卓的Dalvik虚拟机选择dex文件来替代class文件。

class文件合并为dex文件这个过程,主要由 Android SDK 中的dx工具(在较新的Android Gradle插件中被d8工具替代)完成。通过这个转换过程,多个class文件被合并转换成一个更紧凑、更适合移动设备的dex文件,从而减小了应用的大小,提高了加载和执行效率。

class文件转换成dex文件的过程称为”dexing”,主要通过以下步骤完成:

  • 收集所有的class文件: 首先,系统会收集所有需要转换的class文件。这些文件通常来自于你的Java源代码编译后的结果,以及你项目中使用的库。
  • 解析class文件: 每个class文件都会被解析,提取其中的常量池、方法、字段等信息。
  • 合并常量池: dex格式的一个主要优化是合并所有class文件的常量池。相同的字符串、类型和方法引用只会在dex文件中出现一次,大大减少了重复数据。
  • 重新编码数据: class文件中的数据会被重新编码为dex格式。这包括将Java字节码转换为Dalvik字节码,以及重新组织类、方法和字段的结构。
  • 优化: 在转换过程中,还会进行一些优化,例如删除未使用的字段和方法,内联一些简单的方法调用等。
  • 生成dex文件: 最后,所有处理后的数据被写入到一个或多个dex文件中。一个dex文件可以包含多个class的内容。
  • 验证: 生成的dex文件会经过验证,确保其格式正确,可以被Dalvik虚拟机(或ART运行时)正确加载和执行。

二者格式对比如下:

JVM和Dalvik运行流程

JVM基于栈操作

每个Java线程都有自己的 Java虚拟机栈 ,用于存储方法调用的信息,包括局部变量、部分结果和方法调用/返回等。

当一个方法被调用时,会在栈上创建一个新的 栈帧(Stack Frame) 。栈帧包含局部变量表,操作数栈,指向运行时常量池的引用,方法返回地址等信息。

JVM使用基于栈的指令集,这意味着大多数操作都是通过压栈和出栈来完成的。例如:

int foo(int a, int b) {
  return (a + b) * (a - b);
}

转换后的字节码指令为:

  int foo(int, int);
    Code:
       0: iload_1 // 将局部变量a压入栈
       1: iload_2 // 将局部变量b压入栈
       2: iadd    // 弹出栈顶的两个元素,相加,结果压入栈
       3: iload_1 // 将局部变量a压入栈
       4: iload_2 // 将局部变量b压入栈
       5: isub    // 弹出栈顶的两个元素,相减,结果压入栈
       6: imul    // 弹出栈顶的两个元素,相乘,结果压入栈
       7: ireturn // 返回栈顶元素作为方法结果

Dalvik基于寄存器操作

在Dalvik虚拟机中,寄存器是虚拟的,不同于物理CPU中的硬件寄存器。这些虚拟寄存器实际上是存储在内存中的,用于存储局部变量、方法参数、中间计算结果等。

Dalvik虚拟机使用基于寄存器的指令集,这意味着大多数操作都是通过寄存器来完成的。还是上面同样的计算方法:

int foo(int a, int b) {
  return (a + b) * (a - b);
}

转换后的字节码指令

0000: add-int  v0, v3, v4
0002: sub-int  v1, v3, v4
0004: mul-int/2addr  v0, v1
0005: return  v0

add-int是一个需要两个操作数的指令,其指令格式是:add-int vAA, vBB, vCC。其指令的运算过程,是将后面两个寄存器中的值进行(加)运算,然后将结果放在(第一个)目标寄存器中。其余类似

计算流程如下图:

内存对比

一个JVM进程所处的Java虚拟机的内存可以用下面这张热门图片概括:

jvm_ram

Dalvik虚拟机的内存模型略有不同。

Dalvik内存模型

Dalvik虚拟机用来分配对象的堆划分为两部分,一部分叫做Active Heap,另一部分叫做Zygote Heap。

Android系统启动后,会有一个Zygote进程创建第一个Dalvik虚拟机,它只维护了一个堆。以后启动的所有应用程序进程是被Zygote进程fork出来的,并都持有一个自己的Dalvik虚拟机。在创建应用程序的过程中,Dalvik虚拟机采用 COW策略 复制Zygote进程的地址空间。

COW策略,即Copy-On-Write,一开始的时候(未复制Zygote进程的地址空间的时候),应用程序进程和Zygote进程共享了同一个用来分配对象的堆。当Zygote进程或者应用程序进程对该堆 进行写操作时,内核就会执行真正的拷贝操作 ,使得Zygote进程和应用程序进程分别拥有自己的一份拷贝,这就是所谓的COW。因为copy是十分耗时的,所以必须尽量避免copy或者尽量少的copy。

为了实现这个目的,当创建第一个应用程序进程时,会将已经使用了的那部分堆内存划分为一部分作为 Zygote堆 ,还没有使用的堆内存划分为另外一部分称为 Active堆

Zygote Heap 堆内存中, 有一部分区域的内存是只读的, 如系统相关的库, 共享库, 预置库, 这些内存数据所有应用公用。这些预加载的类、资源和对象可以在Zygote进程和应用程序进程中做到 长期共享 。这样既能减少拷贝操作,还能减少对内存的需求。

这样只需把zygote堆中的内容复制给应用程序进程就可以了。以后无论是Zygote进程,还是应用程序进程,当 它们需要分配对象的时候,都在Active堆上进行 。这样就可以使得Zygote堆尽可能少地被执行写操作,因而就可以减少执行写时拷贝的操作。

DVM特点内存模型图

Card Table:用于 DVM Concurrtent GC,当第一次进行垃圾标记后,记录垃圾信息。 Heap Bitmap:分为两个,Live Bitmap 用来记录上次 GC 存活的对象,Mark Bitmap 用来记录这次 GC 存活的对象。 Mark Stack:在 GC 的标记阶段使用的,用来遍历存活的对象。

DVM垃圾回收

Dalvik垃圾收集主要是 mark-sweep 算法实现的。 mark-sweep 算法分为两个阶段,即mark阶段和sweep阶段。Dalvik的GC过程,可以大致归纳为如下过程:

  • 整个标记开始之前,首先遍历一次堆地址空间。
  • Mark阶段: 从对象的根集对象开始标记引用对象。
  • Sweep阶段: 回收没有被标记的对象占用的内存。

Mark标记阶段

这里涉及到的一个核心概念就是我们怎么标记对象有没有被引用的,换句说就是通过什么数据结构来描述对象有没有被引用。

事实上,总共使用了两个bitmap, 一个是Live bitmap,一个是Mark bitmap。 这两个bitmap里的每一位对应一个对象,某个对象被引用了,就标1,没引用就标0。

Livebitmap主要用来标记上一次GC时被引用的对象,也就是那些没有被回收的对象,而markbitmap主要用来标记本轮 当前GC有被引用 的对象。因此那些 在Live bitmap中标为1,而在mark bitmap中标为0 的对象,就是需要回收的对象。mark bitmap实际上就是live bitmap的一个子集。

标记的STW现象

在mark阶段,要求除了GC线程外,其他的线程都需要停止,否则就可能不能正确的标记每个对象,因为可能刚标记完又被修改引用等等情况的发生。这种现象叫stop the world,会导致该应用程序中止执行,

在整个mark开始时,GC会先不得不中止一次程序的运行,从而对堆地址空间进行一次遍历,这次遍历可以获得每一个应用程序分配的对象,就能确认每个对象在内存堆中的大小、起始地址等等信息。 这个停顿在dalvik里是不得不做的事情 ,每次GC都会必须触发一次堆地址空间的遍历引起的停顿。

为了减少stop the world带来的负面影响,dalvik的GC采用了分阶段并行标记Concurrent的方案。分为了两个子阶段:

  • 第一个阶段是标记根集对象,这个阶段是不允许GC线程之外的线程运行的。
  • 第二个阶段是标记根集对象引用的对象,允许其他线程运行。

所谓根集对象,就是指在GC线程开始的时候,那些 被全局变量、栈变量和寄存器等引用的对象 。通过这些根集变量,可以顺着它们找到其余的被引用变量,其实这就是可达性分析。比如说,假如发现了一个栈变量引用一个对象,而这个对象又引用了另外一个对象,那这个被引用的对象也会被标记为正在使用。这个标记“被根集对象引用的对象”的过程就是第二个子阶段。

并行策略很容易想到的一个问题,就是在第二个阶段执行的过程中,如果某个运行的线程修改了一个对象了内容,由于很有可能引用了新的对象,所以这个对象也必须要记录起来。否则会发生被引用对象还在使用却被回收。

这种情况出现在只进行部分GC的情况,这时候 Card Table 的作用就是用来记录非GC堆对象对GC的堆对象的引用。

Dalvik的堆空间,分为zygote heap 和 active heap。前者主要存放一些在zygote时就分配的对象,后者主要用于之后新分配的空间,

Dalvik虚拟机进行部分垃圾收集时,实际上就是 只收集在Active heap上分配的对象 。Card Table就是用来记录在Zygote heap上分配的对象 在GC执行过程中 对在Active heap上分配的对象的引用。

Card table由一系列card组成,一个card实际上就是一个字节,它的值分为clean和dirty两种。如果一个Card的值是 CLEAN ,就表示与它对应的对象在Mark第二个子阶段 没有被程序修改过 。如果一个Card的值是 DIRTY ,就意味着 被程序修改过 。对于这些被修改过的对象。需要在Mark第二子阶段结束之后,再次禁止GC线程之外的其它线程执行,以便GC线程再次根据Card Table记录的信息对被修改过的对象引用的其它对象 进行重新标记 。这个二次标记的过程就是非并行的,确保本次Mark流程的标记都是准确的。

由于Mark 第二子阶段被修改的对象不会很多 ,这样就可以保证第二次子阶段结束后,再次执行标记对象的过程是很快的,因而此时对程序造成的停顿非常小。

在mark阶段,主要是标记的第二个子阶段,dalvik是采用递归的方式来遍历标记对象。但是这个递归并不是像一般的递归一样借助一个递归函数来实现,而是使用一个叫做mark stack的栈空间实现。大致过程是:一个被引用的对象在标记的过程中,先被标记,然后放在栈中,等该对象的父对象全部被标记完成后,依次弹出栈中的每一个对象,并标记其引用,然后把其引用再丢到栈中。

采用mark stack栈而不是函数递归的好处是:首先可以 采用并行的方式 来做,将栈进行分段,多个线程共同将栈中的数据递归标记。其次,可以 避免函数堆栈占用较深

至此,差不多介绍了dalvik的GC的mark阶段的过程。我们可以发现,在mark阶段,一共有3次停顿:

  • 一次是在标记开始前遍历堆地址空间的停顿
  • 一次是在标记的第一个阶段标记所有根集对象时的停顿
  • 还有一次是在标记的第二个子阶段完成后处理card table时的停顿。

这3次停顿的时间直接影响了android上应用程序的表现,尤其是 卡顿现象 ,因此ART在这块有重点改进,等会介绍ART上的过程。

Sweep清除阶段

其实sweep阶段就很简单了,在mark阶段已经提到过,GC时回收的是在live bitmap里标为1而在mark bitmap里标为0的对象。

而这个mark bitmap实际上就是live bitmap的子集,因此在sweep阶段只需要处理二者的差集即可,在回收掉相应的对象后,只需要 再把live bitmap和mark bitmap的指针调换一下 ,即这次的mark bitmap作为下一次GC时的live bitmap,然后清空live bitmap,等到下一次GC流程开始,用来标记下一次的可回收对象。

Sweep的过程,在ART里没什么太大变化,而由于在android 5.0源码中已经去掉了dalvik,这环节的具体解释就在ART部分分析,但是实际上在sweep阶段dalvik和ART二者没有太大区别,因为主要只是处理相应的bitmap的对应的对象的内存,ART也没有什么优化的地方。

Art虚拟机

Art虚拟机是在Android 4.4就引入了,这个版本上留了一个开关供调试选择,其实就是一个标记位,手动开关更改这个值,重启系统之后就是使用上次选择的新的虚拟机方案。5.0版本之后,彻底移除了Dalvik虚拟机,完全使用ART虚拟机。

Dalvik之所以要被ART替代包含下面几个原因:

  • Dalvik是为32位设计的,不适用于64位CPU。
  • 单纯的字节码解释加JIT编译的执行方式,性能要弱于本地机器码的执行。
  • 无论是解释执行还是JIT编译都是单次运行过程中发生,每运行一次都可能需要重新做这些工作,这样做太浪费资源。
  • 原先的垃圾回收机制不够好,会导致卡顿。

先接上文介绍下Art的垃圾回收策略,再介绍JIT,AOT等编译流程。

垃圾回收

ART同样采用了自动GC的策略,并且同样不可避免的使用到了经典的mark-sweep算法。

Art虚拟机的标记清除垃圾回收,根据轻重程度不同,分为三类, sticky,partial,full 。可以看到,ART里的GC的改进,首先就是收集器的多样化。 而根据GC时是否暂停所有的线程分类并行和非并行两类。所以在ART中一共定义了6个mark-sweep收集器。参看art/runtime/gc/heap.cc可见。

根据不同情况,ART会选择不同的GC collector进行GC工作。其实最复杂的就是 Concurrent Mark Sweep 收集器 。如果理解了最复杂的Concurrent Mark Sweep算法,其他5种GC收集器的工作原理就也理解了。同样的,垃圾回收工作从整体上可以划分两个大的阶段:Mark 和 Sweep。

Mark阶段

最重要的提升就是这个阶段 只暂停线程一次。将dalvik的三次缩短到一次 ,得到了较大的优化。和dalvik一样,标记阶段完成的工作也是完成从根集对象出发,进行递归遍历标记被引用的对象的整个过程。用到的主要的数据结构也是同样的 live bitmap和mark bitmap,以及card table和在递归遍历标记时用到的mark stack。

一个被引用的对象在标记的过程中先被标记,然后存入mark stack中,等待该对象的父对象全部被标记完成,再依次弹出栈中每一个对象然后,标记这个对象的引用,再把引用存入mark stack,重复这个过程直至整个栈为空。这个过程对mark stack的操作使用以及递归的方法和dalvik的递归过程是一样的。

但是在dalvik小节里提到了,在标记时mark阶段划分成了两个阶段,第一小阶段是禁止其他线程执行的,在mark两个阶段完成后处理card table时也是禁止其他线程执行的。

在ART里做出了改变,即 先Concurrent标记两遍 ,也就是说两个子阶段都可以允许其他线程运行了。然后 再Non-Concurrent标记一遍 。这样就大大缩短了dalvik里的第二次停顿的带来的卡顿时间。这个改进非常重要。

allocation stack

在标记开始阶段,有别于dalvik的要暂停所有线程 进行堆地址空间的遍历 来确定所有的对象的大小,地址信息。ART去掉了这个过程,替代的是 增加了一个叫作allocation stack结构 ,所有新分配的对象的信息会记录到allocation stack。

然后在Mark的时候,再在Live Bitmap中打上live的标记。Allocation stack和live stack其实是一个工作栈和备份栈。当在GC的时候,需要处理allocation stack,那么会把两个stack互换。新分配的对象会压到备份栈中,这个时候备份栈就当作新的工作栈。之前的allocation stack就当作本轮gc的 所有堆对象的记录栈 。这样一来,dalvik在每一次GC时产生的第一次停顿就完全消除了,从而产生了巨大的性能提升。

Live Stack和Live Bitmap名称很像,但其实是完全不同的东西。Live Bitmap用于记录当前VM进程中所有存在的对象,包括未标记和已标记的对象。与Mark Bitmap配合用于确定可回收的垃圾对象。Live Stack是allocation stack的备份栈,主要用于优化GC性能,快速访问新分配对象。

关于card table,和dalvik依旧类似,每个card用一个字节来描述。ART里多了一个结构ModUnionTable,是和card table配合使用的。

前面在ConCurrent的情况下,经过了两轮的递归遍历,基本上已经标记扫描的差不多了。但由于应用程序主线程是在一直运行的,不可避免地会修改之前已经mark过的bitmap。因此,需要 第三遍扫描,这次就需要在stop the world的情况下进行遍历 ,主要过程也就是上文提到的对card table的操作等等。

这次遍历扫的时候,除了重新标记根集以外,还需要扫描card table中Dirty Card的部分。关于live bitmap和mark bitmap的使用,ART和dalvik在这一块没有多少区别。Live Bitmap记录了当前存在于VM进程中所有的未标记的对象和标记过的对象。Mark Bitmap经过了Mark 的过程,记录了当前VM进程中所有被引用的object。Live Bitmap和Mark Bitmap中间的差集,便是所有为被系统引用的object,即是可以回收的垃圾了。

经过了前面3次扫描以及Mark,我们的mark bitmap已经很完整了。但是值得注意的是,由于Sweep的操作是对应于live bitmap,即那些在live bitmap中标记过,却在mark bitmap中没有标记的对象。也就是说,mark bitmap中标记的对象是live bitmap中标记对象的子集。

但目前为止live bitmap标记的对象还不是最全,因为前文有提到过,为了消除dalvik的第一次停顿,在扫描期间,Art计入了allocation stack中的对象,还没有标记。Allocation stack先“搁置”起来不让后面的主线程使用,启用备份的的live stack。

void Heap::SwapStacks() {
    allocation_stack_.swap(live_stack_);
}

即在下一次垃圾回收开始时,将allocation stack和live stack进行交换。确保本轮处理的对象信息是最新的。

Sweep阶段

在完成了mark阶段后,对应已经标好的live bitmap和mark bitmap,需要进入sweep来回收相应的垃圾。Sweep阶段就是把那些二者的差集所占用的内存回收掉。

Finish阶段

不同于Dalvik,ART中可以归纳为有一个第三个阶段,就是类似的一个finish阶段。

void MarkSweep::FinishPhase() {
	base::TimingLogger::ScopedSplit split("FinishPhase", &timings_);
	// Can't enqueue references if we hold the mutator lock.
	Object* cleared_references = GetClearedReferences();
	Heap* heap = GetHeap();
	timings_.NewSplit("EnqueueClearedReferences");
	heap->EnqueueClearedReferences(&cleared_references);
	......
}

因为之前说过mark bitmap是live bitmap的一个子集,而mark bitmap中包含了所有的正在被引用的的非垃圾的对象,因此需要交换mark bitmap和live bitmap的指针,使mark bitmap作为下一次GC的live bitmap,并且重置新的mark bitmap。

//Clear all of the spaces' mark bitmaps.
for (const auto& space : GetHeap()->GetContinuousSpaces()) {
	if (space->GetGcRetentionPolicy() != space::kGcRetentionPolicyNeverCollect) {
		space->GetMarkBitmap()->Clear();	
	}
}
mark_stack_->Reset();

另外,需要指出的是,由于mark stack的目的是为了方便标记的递归,所以在Finish阶段,也需要把mark stack给清空,至于实现可以看以上代码行。

sticky mark sweep收集器

其实sticky mark sweep的主要步骤也是和mark sweep的过程大致一样,主要完成三次并发的mark阶段,然后进行一个stop the world的非并发进行一次对堆对象的遍历。

void StickyMarkSweep::BindBitmaps() {
	PartialMarkSweep::BindBitmaps();
	WriterMutexLock mu(Thread::Current(), *Locks::heap_bitmap_lock_);
	// For sticky GC, we want to bind the bitmaps of all spaces as the allocation stack lets us
	// know what was allocated since the last GC. A side-effect of binding the allocation space mark
	// and live bitmap is that marking the objects will place them in the live bitmap.
	for (const auto& space : GetHeap()->GetContinuousSpaces()) {
		if (space->GetGcRetentionPolicy() == space::kGcRetentionPolicyAlwaysCollect) {
			BindLiveToMarkBitmap(space);
		}
	}
	GetHeap()->GetLargeObjectsSpace()->CopyLiveToMarked();
}

但是可以通过实现方法发现,有一个 getGcRetenionPolicy ,获取的是一个枚举。

Sticky Mark Sweep 和 Full Mark Sweep 的主要区别为,Full Mark Sweep:回收整个堆内存空间。Sticky Mark Sweep:只回收 自上次 GC 以来新分配的对象和被修改过的对象所在的区域

只有符合总是收集的条件的,就把live bitmap和mark bitmap绑定起来。其余的过程和full是一样的。Sticky Mark sweep只扫描自从上次GC后被修改过的堆空间,并且也只回收自从上次GC后分配的空间。Sticky是只回收kGcRetentionPolicyAlwaysCollect的space。不回收其他两个,因此sticky的回收的力度是最小的。作为最全面的full mark sweep, 上面的三个策略都是会回收的。

partial mark sweep收集器

这是mark sweep收集器里使用的最少的GC收集策略,回收部分堆空间,通常包括年轻代和部分老年代。

按照官方文档,一般是使用sticky mark sweep较多。这里有一个概念就是吞吐率,即一次GC释放的字节数和GC持续的时间(秒)的比值。由于一般是sticky mark sweep进行GC,所以当上次GC的吞吐率小于同时的partial mark sweep的吞吐率时,就会把下次GC收集器从sticky变成partial。但是在partial执行一次GC后,就仍然会恢复到stick mark sweep收集器。

阅读源码发现,partial重写了父类的成员函数。

其实分析这些可以发现,从full mark sweep到partial mark sweep到stick mark sweep,GC的力度是越来越小的,因为 可以回收的越来越少 。之所以说回收力度大,就是指可以回收的space多,比如上图的partial, 是不回收 kGcRetentionPolicyFullCollect ,但是是会回收 kGcRetentionPolicyAlwaysCollect 的space的。

kGcRetentionPolicyFullCollect 表示该内存空间只在完全(Full)垃圾回收时才会被收集。kGcRetentionPolicyAlwaysCollect 表示该内存空间在每次垃圾回收时都会被收集,无论是哪种类型的 GC。

因此partial mark sweep每次执行一次GC后,就会自动切换到sticky策略,这样才能使系统更流畅得进行GC,并减少了GC带来的消耗。。

其他区别

其实观察space目录的文件可以发现,有一个新的概念就是large object space。事实上,ART还引入了一个新的的方法就是 大对象存储空间(large object space,LOS) ,这个空间与堆是相互独立的,但是仍然是驻留在应用程序的内存空间中。方便让ART可以更好的管理较大的对象,比如android里的bitmap。在dalvik中,在对堆空间进行分段时,占用空间较大的对象会带来一些问题。例如,在堆里分配一个 bitmap大对象时,由于占用空间较大,可能引起GC的启动次数也会增加,从而增加了开销 。有了LOS,GC收集器因堆空间分段而引发调用次数将会大大降低,这样垃圾收集器就能做更加合理的内存分配,从而降低运行时开销。

实验对比证明

老罗的博客里做的一些实验对比,证明了ART的GC的性能确实比dalvik的要好。

可以看到,在dalvik模式下刚启动支付宝的几秒内,触发了28次GC事件,总共停顿耗时4657ms。而在ART模式下,可以看到一共触发了2次GC事件,共耗时231.099ms。

在dalvik模式下刚启动百度地图的几秒内,触发了26次GC事件,总共停顿耗时5371ms。而在ART模式下,可以看到一共触发了4次GC事件,共耗时497.162ms。

对比可以看到,ART下的GC的性能明显提升了,几乎可以说是 提升了十倍左右 ,这是一个数量级的提升,GC环节带来的性能提升还是非常明显。

复制算法和标记清除算法哪个更好?

在垃圾回收中,复制算法标记-清除算法的性能优劣取决于具体场景,两者的速度差异主要与堆内存状态、对象存活率以及硬件条件相关。

复制算法,对于 对象存活率低时(如新生代垃圾回收),只需复制少量存活对象,效率极高。复制后堆空间紧凑,无碎片,分配新对象只需移动指针。适合对延迟敏感的场景(如Young GC)。主要缺点有两个,一是内存浪费,需保留一半空间作为空闲区(空间利用率50%)。二是存活率高时性能骤降,若大部分对象存活(如老年代),复制开销比较大。

标记-清除算法,在对象存活率高时,只需标记并清理少量垃圾,无需复制存活对象。内存方面,无需预留空间,适合堆内存紧张的场景。缺点就是内存碎片化,可能导致分配大对象时触发压缩。还有两次遍历开销**:标记和清除阶段需扫描整个堆,存活对象多时较慢。

实际应用

现代GC通常混合使用多种算法以平衡吞吐量、延迟和空间开销。

  • 新生代:使用复制算法(如Serial、Parallel Scavenge)。
  • 老年代:使用标记-清除或标记-整理(如CMS、G1)。

若堆内存充足且对象存活率低,复制算法更快。若存活率高或内存有限,标记-清除更高效。

编译流程

JIT

JIT的全称是Just In Time,即 即时编译 ,它”即时”地进行编译,而不是在程序执行前完成所有编译工作。JIT是在运行时进行字节码到本地机器码的编译,这也是为什么Java普遍被认为效率比C++差的原因。无论是解释器的解释还是运行过程中即时编译,都比C++编译出的本地机器码执行多了一个耗费时间的过程。

AOT

AOT,即Ahead of Time,即 提前编译 。当APK 在安装的时候 ,系统会通过一个名称为dex2oat的工具 将APK中的dex文件编译成包含本地机器码的oat文件存放下来 。这样做之后,在程序执行的时候,就可以直接使用已经编译好的机器码以加快效率。

下图描述了Dalvik虚拟机与(Android 5.0上的)ART虚拟机在安装APK时的区别:

从这幅图中我们看到:

  • 在Dalvik虚拟机上,APK中的Dex文件在安装时会被优化成odex文件,在运行时,会被JIT编译器编译成native代码。
  • 在ART虚拟机上安装时,Dex文件会直接由dex2oat工具翻译成oat格式的文件,oat文件中既包含了dex文件中原先的内容,也包含了已经编译好的native代码。

纯 AOT 存在的问题

应用程序编译生成的OAT文件会引用Framework中的代码。一旦系统发生升级,Framework中的实现发生变化,就需要重新修正所有应用程序的OAT文件,使得它们的引用是正确的,这就需要 重新编译所有的应用

在应用安装的时候也是一样的问题,AOT会在安装时将应用的dex文件编译为oat文件存下来,让安装时间对比Dalvik虚拟机也更长。

第三个问题是,编译生成的Oat文件中,既包含了原先的Dex文件,又包含了编译后的机器代码。而实际上,对于用户来说,并非会用到应用程序中的所有功能,因此很多时候编译生成的机器码是一直用不到的。一份数据存在两份结果(尽管它们的格式是不一样的)显然是一种存储空间的浪费。

混合编译

在 Android 7.0 中,Google又为Android添加了即时 (JIT) 编译器。JIT和AOT的配合,是取两者之长,避两者之短:在APK安装时,并不是一次性将所有代码全部编译成机器码。而是在实际运行过程中,对代码进行分析,将热点代码编译成机器码,让它可以在应用运行时持续提升 Android 应用的性能。

JIT编译器补充了ART当前的预先(AOT)编译器的功能,有助于提高运行时性能,节省存储空间,以及加快应用及系统更新速度。相较于 AOT编译器,JIT编译器的优势也更为明显,因为它不会在应用自动更新期间或重新编译应用(在无线下载 (OTA) 更新期间)时拖慢系统速度。

尽管JIT和AOT使用相同的编译器,它们所进行的一系列优化也较为相似,但它们生成的代码可能会有所不同。JIT会利用运行时类型信息,可以更高效地进行内联,并可让堆栈替换 (On Stack Replacement) 编译成为可能,而这一切都会使其生成的代码略有不同。

APK安装阶段

系统不再像纯AOT时代那样对整个APK进行完全编译。取而代之的是,系统会进行一些轻量级的处理:

  • 验证DEX文件
  • 优化DEX文件(例如字节码优化)
  • 生成ODEX文件(优化后的DEX)

这个阶段不会生成大量的本地机器码,从而加快了安装速度。

首次运行

应用以解释模式开始运行。 JIT编译器开始工作:

  • 监控代码执行
  • 识别热点代码
  • 将热点代码编译成本地机器码

编译后的代码存储在JIT代码缓存中,供后续快速访问。同时,系统开始收集应用使用的配置文件信息。

持续运行阶段

JIT编译器持续工作,不断优化热点代码。系统继续收集和更新应用的使用配置文件。随着时间推移,更多的代码可能被JIT编译。

设备空闲充电时:

系统触发”编译守护进程”(compilation daemon)。基于收集的配置文件信息,对应用进行部分AOT编译。这个过程被称为”配置文件引导编译”(profile-guided compilation)。编译结果保存为本地机器码,存储在设备上。

后续运行

应用启动时,系统会加载之前AOT编译的代码。对于未编译的部分,继续使用解释执行和JIT编译。JIT编译器继续工作,可能会编译之前未被AOT编译的代码。

长期优化

系统会定期(通常在设备空闲充电时)重新评估和更新AOT编译的代码。这个过程会考虑最新的使用模式,可能会编译新的热点代码,或者放弃编译不再频繁使用的代码。

系统更新时

当系统更新时(例如Android版本升级),之前的AOT编译结果可能会被丢弃。 应用会重新经历上述过程,以适应新的系统环境。

总的来说,这种JIT和AOT的混合策略充分利用了两种方法的优点,在 安装速度、启动时间、运行性能和存储空间使用之间取得了很好的平衡 ,显著提升了Android系统的整体用户体验。

【Android进阶】Android I/O 模型

【Android进阶】Android I/O 模型

本文介绍了Android,Linux,Java相结合的一些IO模型和底层原理

Java的I/O模型

Java 的 I/O 模型 指的是 Java 如何处理输入和输出(Input/Output)操作的方式,尤其是在涉及文件、网络、控制台等数据流时的读写机制。不同的 I/O 模型在性能、阻塞行为、线程使用等方面有显著差异。

Java 的 I/O 模型经历了几个发展阶段:传统的阻塞式 I/O(java.io)、NIO(java.nio)和 NIO.2(java.nio.file)。

阻塞 I/O (Blocking I/O - BIO)

Java 传统的 I/O 基于流的概念,将数据视为连续的字节序列或字符序列。

字节流InputStreamOutputStream 是所有字节流的抽象基类,用于处理原始字节数据(例如图片、音频、视频文件)。常见的实现类有 FileInputStream, FileOutputStream, BufferedInputStream, BufferedOutputStream, DataInputStream, DataOutputStream, ObjectInputStream, ObjectOutputStream 等。

字符流ReaderWriter 是所有字符流的抽象基类,用于处理字符数据(例如文本文件)。它们处理字符编码,能够将字节流转换为字符流。常见的实现类有 FileReader, FileWriter, BufferedReader, BufferedWriter, InputStreamReader, OutputStreamWriter 等。

传统的 I/O 是 阻塞式 (Blocking) 的。当一个线程执行 I/O 操作(如读取文件或网络数据)时,它会 一直等待直到操作完成 ,期间该线程无法执行其他任务。这在并发场景下会导致性能问题,因为每个连接可能需要一个独立的线程来处理。适用于客户端数量较少、连接时间较短的场景,例如简单的文件读写。

NIO (Non-blocking I/O - NIO)

NIO 在 Java 1.4 中引入,旨在解决传统 I/O 的阻塞问题,提供更高效、可伸缩的 I/O 操作。

基于 通道 (Channel),表示到实体(如文件、网络套接字)的连接,通过通道读写数据。它与传统 I/O 的流不同,通道是双向的,可以同时进行读写操作。

常见实现类有 FileChannel, SocketChannel, ServerSocketChannel, DatagramChannel 等。所有数据都 通过缓冲区读写 。数据从通道读取到缓冲区,或者从缓冲区写入通道。缓冲区提供了对数据的结构化访问,支持在内存中对数据进行高效操作。最常用的是 ByteBuffer,此外还有 CharBuffer, IntBuffer 等。

NIO模型还允许 单个线程管理多个通道 。通过选择器,一个线程可以监控多个通道上的 I/O 事件(如连接就绪、读就绪、写就绪),从而实现非阻塞 I/O。这大大减少了线程创建和切换的开销,提高了服务器的并发处理能力。

NIO 是 非阻塞式 (Non-blocking) 的。当 I/O 操作无法立即完成时,线程不会被阻塞,而是可以去执行其他任务。当 I/O 准备就绪时,选择器会通知线程。相较于传统I/O,其特点有:

  • 数据读写都通过缓冲区。
  • 非阻塞,I/O 操作不阻塞线程。
  • 多路复用,单个线程可以处理多个 I/O 通道,提高并发性能。
  • 复杂性,相对于传统 I/O,NIO 的编程模型更复杂,需要管理缓冲区、通道和选择器。

适用于高并发、大量连接的场景,如网络服务器。

AIO (Asynchronous I/O)

AIO,也叫 NIO.2 在 Java 7 中引入,主要增加了异步 I/O (Asynchronous I/O) 功能,以及对文件系统操作的增强 (java.nio.file 包)。

异步通道允许在 I/O 操作完成后,通过回调函数或 Future 对象来处理结果,而不需要显式地等待。例如 AsynchronousFileChannel, AsynchronousSocketChannel, AsynchronousServerSocketChannel。还定义了 CompletionHandler 作为I/O 操作完成时调用的回调接口,可以处理成功或失败的情况。

Path/Files,提供了更现代、功能更丰富的文件系统 API,解决了 java.io.File 类的一些局限性,例如更好的异常处理、符号链接支持、元数据访问等。

AIO 是异步式 (Asynchronous) 的。I/O 操作由操作系统执行,当操作完成时,操作系统会通知应用,应用可以注册回调函数来处理结果。这进一步减少了应用层面的线程管理负担。

适用于需要极致性能和扩展性的大型应用,例如高并发网络服务器、大数据处理。

常见分类标准

阻塞和非阻塞

阻塞IO是指在执行IO操作时,如果没有数据可用或者IO操作还没有完成,那么当前线程会被挂起,直到数据准备好或者IO操作完成。这种方式会导致线程阻塞,无法执行其他任务,适用于需要等待IO操作完成的场景。

非阻塞IO是指在执行IO操作时,如果没有数据可用或者IO操作还没有完成,当前线程不会被阻塞,而是会立即返回一个状态码或者错误码,告诉调用者当前IO操作还没有完成。这种方式不会导致线程阻塞,适用于需要同时处理多个IO操作的场景。

Java 中的 NIO 是非阻塞 IO,当用户发起读写的时候,线程不会阻塞,之后,用户可以通过轮询或者接受通知的方式,获取当前 IO 调度的结果。

缓冲IO和直接IO

非直接IO也称为缓冲IO,是大多数操作系统默认的文件访问方式。数据先被 复制到内核缓冲区 ,然后再 从内核缓冲区复制到用户空间 。可以减少实际磁盘操作次数,利用预读(read-ahead)和延迟写(write-behind)优化性能。但是数据需要在内核和用户空间之间 多次拷贝 ,在某些场景下可能增加延迟。适合小文件或随机访问

直接IO绕过内核缓冲区,直接在用户空间和存储设备之间传输数据。数据直接在用户空间和设备间传输,可以 减少数据拷贝次数 。这种方式,每次IO操作都是实际的设备操作。同时要求IO大小和内存对齐符合设备要求。适合大文件传输或已知访问模式的应用

缓冲是针对标准库的

Linux 标准库定义了很多操作系统的基础服务,比如输入/输出、字符串处理等等。Android 操作系统的标准库是 Bionic ,它可是应用层联系内核的桥梁,我们也可以通过 NDK 访问 Bionic。

使用标准库进行 IO 我们称为缓冲 IO,我们读文件的时候,经常遇到,读完一行才会让输出,在 Android 内部也做了类似的处理。

直接是针对内核的

使用 Binder 跨进程传递数据的时候,需要 将数据从用户空间传递到内核空间 ,非直接 IO 也这样,内核空间会多做一层页缓存,如果做直接 IO,应用程序会直接调用文件系统。

Android 的 Binder 通信 既不是 直接 I/O,也不是 非直接 I/O ,而是一种 进程间通信 机制。它主要依赖 内存映射(mmap)内核缓冲区 来实现高效的数据传输,而不是传统的文件 I/O 方式。Binder 使用 mmap() 在内核和用户空间之间建立 共享内存区域,避免数据在用户态和内核态之间的多次拷贝。发送方(Client)和接收方(Server)通过这块共享内存进行数据交换。Binder 驱动维护一个 内核缓冲区,用于临时存储跨进程传递的数据。数据先写入内核缓冲区,再通过共享内存传递给目标进程。Binder 通过 mmap 实现 一次拷贝(用户空间 → 内核缓冲区 → 目标进程用户空间),提高效率。Binder 不属于传统IO,因为它 不涉及磁盘读写,而是纯内存操作 (进程间通信)。

缓冲和非直接 IO 就像 IO 调度的一级和二级缓存,为什么要做这么多缓存呢?因为操作磁盘本身就是消耗资源的,不加缓存频繁 IO 不仅会耗费资源也会耗时。

Android平台的 IO

在 Android 平台上,IO(输入/输出)操作是应用与外部世界(如文件系统、网络、硬件设备)进行交互的基础。

首先确定一个原则,Android 平台的任何耗时操作,都应该在后台线程中进行,主线程需要尽量保持不出现耗时大于16ms的非UI任务,避免出现卡顿现象。主线程的事件循环如果被卡顿5s以上,还会出现ANR的问题。

以下是 Android 平台上常见的 IO 模型及其特点:

阻塞 IO (Blocking I/O)

这是最简单、最直观的 IO 模型。当一个线程执行 IO 操作时(例如,从文件中读取数据,或者向网络发送数据),该线程会被阻塞,直到 IO 操作完成。编程模型简单,易于理解和实现。

需要注意的是,如果在 Android 的主线程(UI 线程)上执行阻塞 IO 操作,会导致应用无响应(ANR - Application Not Responding)错误,给用户带来糟糕的体验。在高并发场景下,每个连接都需要一个线程,线程切换的开销很大。

所以,仅适用于 非常小的、不频繁的 IO 操作,或者在专门的后台线程中进行。例如应用启动时,需要从 res/raw 或 assets 目录读取一个非常小的、固定的配置字符串,比如一个 API Key 或者一个版本号,并且这个读取操作是在一个 独立的后台线程中 进行的。

非阻塞 IO (Non-blocking I/O)

线程在进行 IO 操作时,如果数据尚未准备好,IO 调用会立即返回一个状态码,而不是阻塞线程。开发者需要通过轮询(polling)来检查 IO 操作是否完成。避免了线程阻塞,提高了线程的利用率。

这种非阻塞的模式,需要不断轮询,增加了编程的复杂性。而且频繁的轮询会消耗大量的 CPU 资源。

在 Android 开发中,直接使用纯非阻塞 IO 的场景相对较少。例如要自定义一个 网络服务器框架的底层 ,网络通信库或服务器框架(例如,一个模拟 Netty 行为的 Android 本地服务器),你可能会利用 Java NIO 的 SocketChannel 在非阻塞模式下读写数据。这种模式的编程难度很高,需要开发者手动管理缓冲区、检查返回状态码,并且处理各种边缘情况。

多路复用 IO (I/O Multiplexing)

这种模型允许单个线程同时监听多个 IO 流(或文件描述符)的事件。当任何一个 IO 流准备好读写时,系统会通知该线程,然后线程可以对相应的 IO 流进行操作。常见的实现包括 selectpollepoll(在 Linux 内核中)。

一个线程可以处理多个连接,避免了大量线程创建和切换的开销。可以避免不必要的阻塞,线程只在有 IO 事件发生时才被唤醒。同样的,编程模型相对复杂,需要对底层系统调用有一定了解。

例如在 Android 应用中,如果需要实现一个轻量级的 本地服务器或者 P2P 连接 ,多路复用 IO 是一种高效的选择。

还有 异步网络请求库的底层 ,许多流行的网络请求库(如 OkHttp)底层都可能利用了多路复用 IO 的思想来管理并发网络连接。

异步 IO (Asynchronous I/O - AIO)

线程发起 IO 操作后,立即返回,无需等待 IO 完成。当 IO 操作真正完成时,系统会通知发起者(通常通过回调函数、事件或 Future/Promise 模式)。

线程无需等待 IO 完成,可以立即执行其他任务,进一步提高了并发性。相对于轮询, 回调函数 的方式可以简化编程逻辑。如果是 RxJava 这种框架,当嵌套的回调过多,可能导致代码难以阅读和维护(Callback Hell)。

使用上,例如 Retrofit + OkHttp + Coroutines 推荐组合。Retrofit 定义接口,OkHttp 执行实际的网络请求,Coroutines (suspend 函数和 Dispatchers.IO) 负责在后台线程执行请求并在请求完成后通知 UI 线程。

还有像大文件和频繁的读写。

suspend fun saveLargeFile(data: ByteArray, fileName: String) {
    withContext(Dispatchers.IO) {
        val file = File(context.filesDir, fileName)
        FileOutputStream(file).use { fos ->
            fos.write(data)
        }
    }
}

扩展:Android 阻塞IO 调用流程

一个系统调用的大致工作流程:

  • 用户程序发起请求: 用户程序通过调用库函数(这些库函数内部会封装系统调用)或直接使用 syscall 指令,将系统调用号和参数传递给操作系统。
  • 切换到内核态: CPU从用户态(用户程序运行的权限较低的状态)切换到内核态(操作系统核心运行的权限较高的状态)。这个切换通常通过软中断实现。
  • 内核处理请求: 操作系统内核根据系统调用号找到对应的内核函数,并执行相应的操作。
  • 返回结果: 内核完成操作后,将结果(成功或失败,以及相关数据)返回给用户程序。
  • 切换回用户态: CPU切换回用户态,用户程序继续执行。

绝大部分的非直接IO都会通过系统调用这层桥梁来进行,应用进程不会直接联系内核。

VFS

在 Android 平台上,虚拟文件系统 (Virtual File System, VFS) 是一个至关重要的概念,它为应用程序提供了一个统一的文件访问接口,而无需关心底层存储的实际物理结构和文件系统类型。VFS 位于文件系统之上,抽象了不同文件系统之间的差异,使得应用程序可以以相同的方式处理各种文件和目录。

常常有下列的对象(C语言中的结构体)构成:

blogs_linux_vfs

  • 超级块 (Superblock): 每个文件系统都有一个超级块,它包含了文件系统的元数据,如文件系统类型、块大小、总块数等。
  • 索引节点 (Inode): 每个文件或目录都有一个索引节点,它包含了文件的元数据,如文件大小、权限、创建时间等。
  • 文件系统操作 (File System Operations): 每个文件系统都有一组文件系统操作函数,用于执行文件系统的各种操作,如打开文件、读取文件、写入文件等。
  • 目录项 (Directory Entry): 目录是一种特殊的文件,它包含了指向其他文件或目录的指针。
  • 文件 (File): 文件是存储在磁盘上的数据,可以是文本、图像、音频等。

不过,光有这些对象可不行,VFS 还得知道如何操作它们,所以,每个对象中还存在对应的操作对象:

  • super_operation 对象:内核针对超级块所能调用的方法
  • inode_operation 对象:内核针对索引结点所能调用的方法
  • dentry_operation 对象:内核针对目录项所能操作的方法
  • file_operation 对象:内核针对进程中打开的文件所能操作的方法

大伙最熟悉的应该是文件,这是我们能够在进程中实实在在能够操作的,比如,在文件的 file_operation 中,就有我们熟悉的读、写、拷贝、打开、写入磁盘等方法。

超级块和索引节点存在于内存和磁盘,而目录项和文件只存在于内存。

我的理解是对于磁盘,索引节点已经足够记录文件信息,并不需要目录项再来记录层级关系;而对于内存来说,为了节省内存,只会把需要用到的文件和目录项所用到的索引节点加入内存,文件系统只有被挂载的时候超级块才会被加入到内存中。

VFS中的缓存

结合本文中的第一张图,我们会发现,VFS 有目录项缓存、索引节点缓存和页缓存,目录项和索引节点我们都知道什么意思,那页缓存呢?

页缓存是由 RAM 中的物理页组成的,对应着 ROM 上的物理地址。我们都知道,现在主流 Android 的 RAM 访问速度高达是 8.5 GB/S,而 ROM 的访问速度最高只有 6400 MB/S,所以访问 RAM 的速度要远远快于 ROM,页缓存的目的也在于此。

当发起一个读操作的时候,内核会首先检查需要的数据是否在页缓存,如果在,直接从内存中读取,我们称之为缓存命中;如果不在,那么内核在读取数据的时候,将读到的数据放入页缓存,需要注意的是,页缓存可以存入全部文件内容,也可以仅仅存几页。

文件系统

VFS 定义了文件系统的统一接口,具体的实现了交给了文件系统,超级块里面的数据如何组织、目录和索引结构如何设计、怎么分配和清理数据,这都是设计一个文件系统必须考虑的!

说白了,文件系统就是用来管理磁盘里的持久化的数据的,对于 Android 来说,最常见的就是 ext4 和 f2fs。

Ext2 (Second Extended File System) 是 Linux 内核最初使用的文件系统之一,由法国软件开发者 Rémy Card 设计。它取代了早期的 Ext 和 Minix 文件系统,解决了它们在文件大小、文件名长度等方面的限制。尽管现在更常用的是 Ext3 和 Ext4(它们在 Ext2 的基础上增加了日志功能),但理解 Ext2 的基本结构对于理解现代 Linux 文件系统仍然非常重要。

Ext2 文件系统结构

Ext2 文件系统将磁盘空间划分为逻辑块 (Blocks),然后将这些块进一步组织成 块组 (Block Groups)。这种设计旨在减少磁盘碎片,并最小化磁头移动,从而提高性能。

每个块组通常包含以下几个部分:

  1. 引导块 (Boot Block):位于磁盘的起始位置,包含用于系统引导的信息。Ext2 文件系统本身并不使用这部分空间。

  2. 超级块 (Superblock):
    • 它是文件系统的核心,包含了文件系统的全局元数据。
    • 重要信息:文件系统的大小、块大小、每个块组的块数量、空闲块和空闲 inode 的数量、文件系统状态(如是否干净卸载)、上次挂载时间、上次写入时间、魔数(标识文件系统类型)等。
    • 冗余备份:为了容错,Ext2 会在每个块组的开始部分(或至少一些块组)存储超级块的副本。如果主超级块损坏,可以使用副本进行恢复。
  3. 块组描述符表 (Group Descriptor Table):
    • 紧跟在超级块之后,它包含了每个块组的描述信息。
    • 重要信息:每个块组中空闲块位图的位置、inode 位图的位置、inode 表的起始位置以及该块组中空闲块和空闲 inode 的数量。
  4. 块位图 (Block Bitmap):
    • 一个位图,用于记录该块组中数据块的分配情况。每个位对应一个数据块,如果位是 1,表示该块已被占用;如果是 0,表示空闲。
    • 操作系统利用块位图来查找空闲块,以分配给文件数据。
  5. Inode 位图 (Inode Bitmap):
    • 类似于块位图,用于记录该块组中 inode 的分配情况。每个位对应一个 inode,1 表示已分配,0 表示空闲。
  6. Inode 表 (Inode Table):
    • 存储该块组中所有 inode 结构体的数组。
    • Inode (Index Node) 是 Ext2 文件系统的核心概念之一,它代表了文件系统中的一个对象(文件、目录、符号链接等)。
    • Inode 包含的信息:
      • 文件类型(普通文件、目录、符号链接等)
      • 访问权限(读、写、执行权限)
      • 文件所有者和所属组
      • 文件大小
      • 创建时间、修改时间、上次访问时间
      • 硬链接计数
      • 指向数据块的指针:这是 Inode 最重要的部分。Inode 本身不存储文件内容,而是存储指向文件实际数据块的指针。Ext2 的 inode 结构通常包含:
        • 直接指针:通常有 12 个,直接指向文件的前 12 个数据块。
        • 一级间接指针:指向一个块,该块中存储了更多的数据块指针。
        • 二级间接指针:指向一个块,该块中存储了一级间接指针的地址。
        • 三级间接指针:指向一个块,该块中存储了二级间接指针的地址。
        • 这种多级索引方式使得 Ext2 能够支持非常大的文件。
  7. 数据块 (Data Blocks):
    • 存储文件实际内容(数据)的区域。这些块由 inode 指向。
    • 目录在 Ext2 中也是一种特殊的文件,它的数据块中存储的是目录项(文件名到 inode 号的映射)。

Ext2 的 I/O 流程 (以读取文件为例)

当应用程序请求读取一个文件时,Ext2 文件系统通常会经历以下 I/O 流程:

  1. VFS 层解析路径名:
    • 应用程序通过系统调用(如 open())传入文件路径名。
    • 操作系统内核的虚拟文件系统 (VFS) 层接收到请求。
    • VFS 层会从根目录开始,逐级解析路径名。对于每个目录,VFS 会:
      • 查找该目录的 inode(通过其父目录的 inode 指向的数据块中的目录项)。
      • 读取该目录的数据块,查找与路径名中下一个组件匹配的目录项。
      • 从目录项中获取对应文件或子目录的 inode 号。
  2. 获取目标文件的 Inode:
    • 一旦 VFS 找到目标文件的 inode 号,它会根据 inode 号计算出该 inode 所在的块组以及在该块组的 Inode 表中的偏移量。
    • 操作系统会从磁盘上读取包含该 inode 的块,并将其加载到内存中。这个 inode 包含了文件的大小、权限和最重要的数据块指针。
  3. 确定数据块位置:
    • 假设应用程序请求读取文件内容的某个偏移量。
    • 文件系统会根据文件偏移量和文件系统的块大小,计算出需要读取的逻辑块号。
    • 然后,文件系统会使用 Inode 中存储的指针来查找该逻辑块对应的物理块地址:
      • 如果是前 12 个块,直接使用直接指针。
      • 如果是后续的块,需要通过一级、二级或三级间接指针来查找实际的数据块地址。这可能涉及到多次磁盘读取,以获取间接块中的指针。
  4. 读取数据块:
    • 文件系统通过计算出的物理块地址,向磁盘控制器发出读取请求。
    • 磁盘控制器将数据从磁盘读取到内核缓冲区。
  5. 数据返回给用户:
    • 内核将从磁盘读取的数据从内核缓冲区复制到应用程序指定的内存缓冲区中。
    • read() 系统调用返回,应用程序可以处理文件数据。

虽然大部分的文件系统也都有超级块、索引节点和数据块,但是各个文件系统的实现却大不相同,这就导致了他们的侧重点也不一样。拿 ext4 和 f2fs 来讲,ext4连续读取大文件更强,占用的空间更小;而f2fs随机 IO 更快

说白了,也就是它们对于空闲空间分配和已有的数据管理方式不一致,不同的数据结构和算法导致了不同的结果。

块IO层

Linux 下面有两大基本设备类型:

  • 块设备:能够随机访问固定大小数据片的硬件设备,硬盘和闪存(下面介绍)就是常见的块设备
  • 字符设备:字符设备只能按照字符流的方式被有序访问,比如键盘和串口

这两个设备的区别就是是否能够随机访问。拿属于字符设备的键盘来说,当我们输入 Hello World 的时候,系统肯定不可以先得到得到 eholl wrodl,这样的话,输出就乱套了。而对于闪存来说,常常是看完这个这些数据库组成的图片,又要读间隔很远的数组块的小说内容,所以读取的块在磁盘上肯定不是连续的。

因为内核管理块设备实在太复杂了,所以就出现了管理块设备的子系统,就是上面说的文件系统。

块设备结构

块设备中常用的数据管理单位:

  • 扇区:设备的最小寻址单元
  • 块:文件系统的最小寻址单元,数倍大于扇区
  • 片段:由数百至数千的块组成

因为 Linux 中常常用的硬盘,这里我有点疑问,这里的管理单位是否和下面闪存管理单位一致?

IO过程

如果当前有 IO 操作,内核会建立一个 bio 结构体的基本容器,它是由多个片段组成,每一个片段都是一小块连续的内存缓冲区。

之后,内核会将这些 IO 请求保存在一个 request_queue 的请求队列中。

如果按照 IO 请求产生的顺序发向块设备,性能肯定难以接受,所以内核会按照磁盘地址对进入队列之前提交的 IO 请求做合并与排序的预操作。

磁盘

移动设备中常用的持久化存储是 Nand 闪存,UFS 又是 Nand 闪存中的佼佼者,其特点是速度更快、体积小和更省电。当今 Android 旗舰机基本上标配 UFS 3.1,它们只是一块儿很小的芯片。

闪存是一种非易失性存储器,即使掉电了,数据也不会丢。闪存的存储单元从小到大有:

  • Cell(单元):是闪存存储的最小单位,根据存储的数量可以分为SLC(1bit/Cell)、MLC(2bit/Cell)、TLC(3bit/Cell)和QLC(4bit/Cell)
  • Page(页):由大量的 Cell 构成,每个 Page 的大小通常是 16 kb,它是闪存能够读取的和写入的最小单位
  • Block(块):每个块由数百至数千的 Page 组成
  • Plane(面):Plane 由数百至数千的 Black 组成
  • Die(逻辑单元):每个 Die 由一个至多个 Plane,是闪存中可以执行命令或者回报状态的最小单元

到Cell这一层,再往下就是MOS管了,通过电压控制电子是否进入存储单元。

blogs_io_all.jpeg

【Android进阶】Android平台常用动画简介

【Android进阶】Android平台常用动画简介

本文介绍了Android平台上的补间动画,属性动画,帧动画,扩展的mp4,pag,lottie,kanzi,unity

Android 平台上公认的系统层主要的动画类型有以下三种:属性动画,补间动画,帧动画

系统原生

补间动画 (View Animation / Tween Animation)

Android 平台最早的动画系统之一,只能对 View对象 进行动画,也称为视图动画。

且只能对 View 的基本变换属性进行动画,包括:

  • 透明度 (Alpha)
  • 旋转 (Rotate)
  • 缩放 (Scale)
  • 平移 (Translate)

需要注意的是,补间动画 改变的是绘制位置,而非View的实际位置。 实际上并没有改变 View 的实际位置、大小等属性,它只是改变了 View 的绘制方式。这意味着即使 View 被动画移动了,它的点击事件响应区域仍在原来的位置。

对于简单的变换动画,补间动画的使用相对简单,可以通过 XML 或代码定义。适用于简单的视图变换,例如按钮点击后的放大缩小效果、图片渐入渐出效果等。对性能要求不高的简单 UI 动画。

使用

首先确认好动画的效果,是平移或是透明度等。在res文件等anim文件夹内,建立一个 translate_anim.xml

<!-- 平移动画 -->
<translate xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="2000"
    android:fromXDelta="0%"
    android:toXDelta="300%"
    android:interpolator="@android:anim/linear_interpolator"/>

在代码中读取这个anim对象,再调用view的 startAnimation 方法。

binding.apply {
    tvViewAnimation.setOnClickListener {
        val anim =
            AnimationUtils.loadAnimation(this@ViewAnimationActivity, R.anim.translate_anim)
        tvViewAnimation.startAnimation(anim)
    }
}

我们就可以看到这个 textView 会平移到其三倍宽度的地方, XDelta 属性即为这个 View 自身宽度的尺度。动画完结view的显示会回到原来的位置,如果想要停在目标位置,可以设置 anim.fillAfter = true

在运动过程中,点击View原来的位置,可以看到动画重新开始了,可以说明View的实际位置没有改变。

原理

Android 系统会不断调用 View 的 onDraw() 方法,在onDraw()方法中,动画会根据当前时间计算出View应该处于的变换状态,然后应用这些变换(通过Canvas的translate()、scale()、rotate()、alpha()等方法),最后绘制View的内容。不断的重绘就形成了动画效果。

而View的实际位置是在 Layout 布局阶段确定的,补间动画并没有改变View的上下左右边界,所以触摸事件的生效区域还在最初 布局 阶段确定的原位置。

属性动画 (Property Animation)

属性动画系统是 Android 3.0 (API level 11) 引入的,它允许你对 任何对象的任何属性 进行动画。这意味着你不仅可以动画视图的属性(如位置、大小、透明度、旋转),还可以动画自定义对象的属性,甚至那些不直接绘制到屏幕上的属性。

属性动画的核心是改变属性的值。你可以指定动画的起始值和结束值,系统会根据时间插值器 (TimeInterpolator) 计算中间值,并通过一个估值器 (TypeEvaluator) 将计算出的值应用到目标对象的属性上。

属性动画不局限于 View,可以对任何 Java 对象进行动画,只要该对象有一个 setter 方法来设置要动画的属性。

有丰富的 API 来控制动画的各个方面,包括:

  • 时长 (Duration): 动画的总时间。
  • 时间插值 (Time Interpolation): 控制动画的变化速度曲线(例如,加速、减速、先慢后快、回弹等)。
  • 重复计数和行为 (Repeat Count and Behavior): 指定动画是否重复播放,以及重复的次数和方式(例如,正向播放、反向播放)。
  • 动画集合 (Animator Sets): 将多个动画组合成一个集合,可以同时播放、按顺序播放或延迟播放。
  • 帧刷新延迟 (Frame Refresh Delay): 指定动画帧的刷新频率。

场景

  • 对视图的各种属性(如位置、大小、透明度、旋转、颜色、背景等)进行复杂、精细控制的动画。 ** 对非视图对象的属性进行动画。
  • 实现自定义的动画效果。
  • Material Design 风格的动画,例如圆形揭露动画、共享元素过渡动画等。

使用

属性动画的使用相对复杂,需要创建一个 Animator 对象,并指定要动画的属性和目标值。然后,将这个 Animator 对象应用到目标对象上。

针对View的属性动画

tvPropAnimation.apply {
    setOnClickListener {
        val animator =
            ObjectAnimator.ofFloat(this, "translationX", 0f, this.width * 3f)
        animator.duration = 2000
        animator.interpolator = LinearInterpolator()
        animator.start()
    }
}

这里我们可以看到,我们创建了一个属性动画,这个动画会让这个 textView 从自身的X轴方向平移到自身宽度三倍的位置。和上面补间动画一样的效果。

针对变量的属性动画

使用ValueAnimator来创建属性动画,ValueAnimator会根据时间插值器计算出中间值,并通过一个估值器将计算出的值应用到目标对象的属性上。

tvVarPropAnimation.apply {
    setOnClickListener {
        val valueAnimator = ValueAnimator.ofInt(this.width, this.width * 3)
        valueAnimator.duration = 2000
        valueAnimator.interpolator = LinearInterpolator()
        valueAnimator.addUpdateListener { animation ->
            this.width = animation.animatedValue as Int
        }
        valueAnimator.start()
    }
}

这个动画会把TextView的宽度在2s内扩大3倍。

帧动画 (Frame Animation / Drawable Animation)

帧动画是指通过按顺序显示一系列 Drawable 资源来创建的动画,类似于电影胶片的原理。

由于需要加载多张图片,如果图片数量过多或分辨率过高,可能会占用较多的内存资源。通常在 XML 文件中定义,指定每个帧的 Drawable 资源和显示时长。

播放序列帧图片,例如加载动画、游戏中的角色跳跃动画、爆炸效果等。精确控制每一帧显示内容的场景。

使用

drawable 文件夹内,建立一个 frame_anim.xml 的文件。

<?xml version="1.0" encoding="utf-8"?>
<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
    android:oneshot="true">
    <item
        android:drawable="@drawable/fragnance_open_00"
        android:duration="50" />
    <item
        android:drawable="@drawable/fragnance_open_01"
        android:duration="50" />
    <item
        android:drawable="@drawable/fragnance_open_02"
        android:duration="50" />
    <item
        android:drawable="@drawable/fragnance_open_03"
        android:duration="50" />
</animation-list>

在代码中读取这个anim对象,一般会使用一个ImageView来承载,触发后,调用 ImageView 的 setBackgroundResource 方法。

binding.apply {
    ivFrameAnimation.setOnClickListener {
        ivFrameAnimation.setBackgroundResource(R.drawable.frame_anim)
        (ivFrameAnimation.background as AnimationDrawable).start()
    }
}   

原理

帧动画的原理是通过不断地切换 Drawable 资源来实现的。系统会根据每个 Drawable 的显示时长,依次显示每个 Drawable,形成动画效果。

小结

在现代 Android 开发中,属性动画 是推荐的首选动画方式,因为它提供了无与伦比的灵活性和强大的控制能力。视图动画和帧动画在某些特定场景下仍有其用武之地,但通常属性动画能够实现它们的功能,并且效果更好。

此外,对于 Jetpack Compose 这种声明式 UI 框架,也有其独特的动画 API,如 animateFloatAsStateAnimatedVisibility 等,它们在底层也是基于属性动画的原理实现的,提供了更简洁的动画开发体验。

三方扩展

Lottie动画

Lottie 是一个由 Airbnb 开发的开源动画库,它的核心理念是将 AE 中创建的动画导出为轻量级的 JSON 文件,然后 Lottie 库可以在各种平台上(包括 Android, iOS, Web, React Native, Windows, HarmonyOS 等)原生渲染这些动画。

Lottie 动画的优势

  • 文件体积小巧,Lottie 动画文件通常比 GIF 或 APNG 等传统位图动画格式小得多。这意味着更快的加载速度和更少的带宽消耗,尤其是在移动端体验上优势明显。
  • 高保真还原,Lottie 能够几乎完美地还原 AE 中设计的动画效果,包括路径动画、形状变换、颜色渐变、蒙版、预合成等,解决了传统方式下设计师和开发者之间协作的难题(即“所见即所得”)。
  • 矢量性,大部分 Lottie 动画是基于矢量图形的,这意味着它们在不同分辨率的屏幕上都能保持清晰锐利,不会出现像素化的问题。
  • 跨平台兼容性,同一个 JSON 文件可以在多个平台上使用,大大提高了开发效率和动画的一致性。
  • 易于集成,Lottie 提供了简洁易用的 API,开发者可以轻松地将 Lottie 动画集成到他们的应用中。

原理

JSON 文件解析:

  • 在应用程序运行时,Lottie 库会读取这个 JSON 文件。
  • 它会解析 JSON 数据,将其映射为 Lottie 内部的数据结构,代表动画中的各种元素和它们的动画属性。

原生渲染

  • Lottie 库根据解析后的数据,利用各个平台原生的绘图 API 进行渲染。
  • 在 Android 上,Lottie 主要通过 Canvas 进行 2D 渲染,并结合原生的 ValueAnimator 等动画组件来实现动画效果。
  • 它会遍历动画的每一帧,计算当前帧每个图层和元素的变换(平移、缩放、旋转、透明度、路径变化等),然后使用 Canvas 的绘图方法(如 drawPath, drawCircle, drawRect 等)将其绘制出来。

使用

添加依赖:

build.gradle (Module: app) 中添加 Lottie 库的依赖。

dependencies {
    implementation 'com.airbnb.android:lottie:6.0.0' // 替换为最新版本
}

放置 JSON 文件

将导出的 .json 动画文件放置在 src/main/assets 目录下。

在布局中添加 LottieAnimationView

<com.airbnb.lottie.LottieAnimationView
    android:id="@+id/animation_view"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:lottie_fileName="your_animation.json"
    app:lottie_autoPlay="true"
    app:lottie_loop="true" />

app:lottie_fileName:指定位于 assets 目录下的 JSON 文件名。 app:lottie_autoPlay:是否自动播放。 app:lottie_loop:是否循环播放。

在代码中控制 你也可以通过代码来加载和控制动画。

LottieAnimationView animationView = findViewById(R.id.animation_view);
// 从 assets 加载
animationView.setAnimation("your_animation.json");
// 从 raw 资源加载
// animationView.setAnimation(R.raw.your_animation);
// 从 URL 加载
// animationView.setAnimationFromUrl("https://example.com/your_animation.json");
animationView.playAnimation(); // 播放
animationView.pauseAnimation(); // 暂停
animationView.cancelAnimation(); // 取消
animationView.loop(true); // 设置循环
animationView.setSpeed(1.5f); // 设置播放速度
animationView.setProgress(0.5f); // 设置播放进度
// ... 更多控制 API

Lottie 是现代移动和 Web 应用中实现高质量动画的强大工具,极大地简化了设计师和开发者之间的协作流程,并提供了出色的性能和灵活性。

PAG动画

PAG动画 是由腾讯开发的一种高效的动画渲染解决方案,全称为 Portable Animated Graphics。它主要用于在移动端、Web 和桌面应用中实现高性能的矢量动画播放,广泛应用于社交、广告、游戏和短视频等领域。

PAG动画的核心特点:

  • 高性能渲染,采用 硬件加速(如OpenGL/Vulkan/Metal),确保动画流畅播放,即使在低端设备上也能保持高帧率。支持 多线程解码,减少主线程压力,提升响应速度。
  • 全平台支持,覆盖 iOS、Android、Web、Windows、macOS 等平台,提供统一的动画体验。支持 Flutter、React Native 等跨平台框架。
  • 矢量动画兼容性,支持从 Adobe After Effects (AE) 直接导出动画(通过PAG插件),保留图层、遮罩、特效等属性。兼容 Lottie 的部分特性,但性能更优,文件更小。
  • 动态编辑能力,运行时可以修改文本、图片、颜色等元素(如替换广告文案或贴图),无需重新导出动画。支持 视频模板,方便替换视频片段。
  • 文件体积小,采用二进制格式(.pag 文件),比JSON格式的Lottie(.json)更紧凑,加载更快。

使用

PAG动画的使用相对简单,主要分为以下几个步骤:

导包

tencent-libpag = { module = "com.tencent.tav:libpag", version.ref = "tencentLibpag" }

放置源文件

将设计师所提供的 .pag动画 文件导入到项目中,一般放置于assets文件夹。

xml布局添加承载的pagView

<com.tencent.libpag.PAGView
    android:id="@+id/pagView"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

代码中加载动画

// 调用
playPAGView(pagView, "anim_file.pag")

//开始播放动效
fun playPAGView(pagView: PAGView?, fileName: String) {
    LogUtils.i(TAG, "playPAGView: fileName:$fileName")
    if (pagView != null) {
        if (pagView.isPlaying) {
            pagView.stop()
        }
        pagView.visibility = View.VISIBLE
        val pf = PAGFile.Load(appContext.assets, fileName)
        pagView.composition = pf
        pagView.setRepeatCount(-1)
        pagView.play()
    }
}

PAG动画凭借其高性能和灵活性,逐渐成为替代Lottie的主流方案,特别适合需要复杂动效和实时编辑的场景。

原理

整体架构如下:

┌───────────────────────┐
│      Android App      │
├───────────────────────┤
│  PAG SDK (Java/Kotlin)│
├───────────┬───────────┤
│ 文件解析层 │ 渲染引擎层 │
├───────────┴───────────┤
│      OpenGL ES/Skia   │
└───────────────────────┘

(1) 文件解析与数据解码

.pag 文件格式:采用二进制编码(而非 Lottie 的 JSON),体积更小,解析更快。解析时仅解码当前帧所需的数据,减少内存占用。采用了多线程解码,在后台线程解析动画数据,避免阻塞 UI 线程。

(2) 渲染引擎(Skia + OpenGL ES)

PAG 的渲染核心依赖: Skia:处理矢量路径(Path)、颜色渐变、遮罩等 2D 图形操作。 OpenGL ES:实现硬件加速,通过 GPU 高效合成图层(类似游戏渲染)。

渲染流程:

图层树构建:解析 .pag 文件后生成图层树(类似 AE 的图层结构)。 帧数据计算:根据当前时间戳计算各图层的变换(位移、旋转、透明度等)。

GPU 绘制:

矢量路径 → 由 Skia 转换为 GPU 可识别的网格(Mesh)。 位图/图片 → 使用 OpenGL 纹理(Texture)渲染。 特效(模糊、渐变)→ 通过 GLSL 着色器实现。

(3) 动画控制与动态修改

时间轴驱动:基于 ValueAnimator 或 Choreographer 同步帧率(通常 60FPS)。

动态替换:

文本替换:运行时修改 PAGTextLayer 的内容。 图片替换:通过 PAGImageLayer 绑定 Bitmap 对象。 播放控制:支持播放、暂停、循环、进度跳转等。

PAG和Lottie对比

简单来说,Lottie动画的文件简单小巧,文件主要基于JSON打包,跨平台特性比较好。

而PAG动画可实现的效果更多,同时文件采用了二进制压缩,二进制格式通常具有更高的压缩率和解析速度,但可读性较差,不易直接修改。

选择 Lottie 还是 PAG 取决于你的具体需求:

  • 如果你主要制作轻量级、矢量化、UI 相关的动画,且对 After Effects 复杂特效没有特别要求,那么 Lottie 通常是更好的选择,它更简洁、轻便。
  • 如果你需要实现After Effects 中更复杂、更炫酷的动画效果,可能包含视频、图片、大量文本动态替换,或者需要制作短视频模板等,那么 PAG 能提供更高的还原度和更强大的运行时编辑能力。

kanzi && Unity 3D

这两个动画比较详细的介绍,此前已经有一篇记录。

【Android集成Unity的两种方式】

【Android进阶】Android平台硬件状态获取总结

【Android进阶】Android平台硬件状态获取总结

本文介绍了Android平台上的一些常用的硬件设备相关的api总结。

纲要转载自掘金老哥的一篇文章,实机测试及扩展而来。 【掘金原文】

检测是手机还是平板

Android中没有提供特定的方法来判断设备是手机还是平板,只能通过别的方式来间接判断,比如通过判断屏幕尺寸。

infoText.text = checkIsTablet()

private fun checkIsTablet(): String {
    val metrics = resources.displayMetrics
    val widthInches = metrics.widthPixels / metrics.xdpi
    val heightInches = metrics.heightPixels / metrics.ydpi
    val diagonalInches = sqrt(widthInches.pow(2.0f) + heightInches.pow(2.0f))
    return if (diagonalInches >= 7.0) {
        "手机还是平板:平板"
    } else {
        "手机还是平板:手机"
    }
}

判断是否为折叠屏

其实在折叠屏没出现的时候,判断手机或者是平板使用上述方法还是够用的,但是在折叠屏面前就显得信心不足了,折叠屏一展开,那就是一个长着平板脸的手机,为了识别折叠屏,Android10出来了一个新的感应器类型TYPE_HINGE_ANGLE,可以通过是否存在这种感应器来识别折叠屏。

private fun checkIsFoldScreen(): String {
    val sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
    val hingeAngleSensor = sensorManager.getDefaultSensor(Sensor.TYPE_HINGE_ANGLE)
    return if (hingeAngleSensor == null) {
        "是否折叠屏:否"
    } else {
        "是否折叠屏: 是"
    }
}

如果说想要具体拿到折叠屏的状态,比如是全展开还是半展开,或者收起状态,就要使用Jetpack WindowManager这个库了,分别可以通过以下api拿到不同的状态。

屏幕密度与密度比例

这两个值相信基本每个项目都会用到,屏幕密度一般用来判断屏幕适配,加载不同的图片资源,密度比例一般用来单位换算,这俩值都可以通过DisplayMetrics来获得

infoText.text = checkScreenDpiAndDensity()

private fun checkScreenDpiAndDensity(): String {
    val displayMetric = resources.displayMetrics
    val dpi = displayMetric.densityDpi
    val density = displayMetric.density
    return "屏幕密度:${dpi}  密度比例:${density}"
}

屏幕像素(宽高)

private fun checkScreenPixel(): String {
    val displayMetrics = DisplayMetrics()
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
        display?.getRealMetrics(displayMetrics)
    }else{
        windowManager.defaultDisplay.getRealMetrics(displayMetrics);
    }
    val wPixel = displayMetrics.widthPixels
    val hPixel = displayMetrics.heightPixels
    return "像素(宽):${wPixel} 像素(高):${hPixel}"
}

物理尺寸

物理尺寸在安卓上单位是英寸,它表示一个屏幕对角线的长度,至于如何计算对角线,就要用到上学时候用到的勾股定理,x,y分别是屏幕的宽高,注意的是由于单位是英寸,所以也要把上面计算出来的像素转换成英寸,具体代码如下

private fun checkPhysicalSize(): String {
    val displayMetrics = DisplayMetrics()
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
        display?.getRealMetrics(displayMetrics)
    }else{
        windowManager.defaultDisplay.getRealMetrics(displayMetrics);
    }
    val widthInches = displayMetrics.widthPixels / displayMetrics.xdpi
    val heightInches = displayMetrics.heightPixels / displayMetrics.ydpi
    val diagonalInches = sqrt(
        widthInches.pow(2.0f) + heightInches.pow(2.0f)
    )
    return "物理尺寸 $diagonalInches"
}

刷新率

刷新率一般就是指Android屏幕上每秒更新画面的频率,单位是赫兹,正常来讲,普通设备的刷新率都为60赫兹,获取刷新率的代码如下

private fun checkRefreshRate(): String {
    val mDisplay = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
        display
    }else{
        windowManager.defaultDisplay
    }
    return "刷新率 ${mDisplay?.refreshRate}"
}

广色域

有的设备支持广色域,有的设备仅仅支持标准色域,广色域的意思是屏幕可以显示比标准色域(sRGB)更加丰富的颜色范围,判断一个设备是否支持广色域的方式如下

private fun checkColorGamut(): String {
    val config: Configuration = resources.configuration
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        val isWideColorGamut: Boolean = config.isScreenWideColorGamut
        val support = if(isWideColorGamut) "支持" else "不支持"
        return "是否支持广色域模式:${support}"
    }
    return "不支持广色域"
}

获取内存(Runtime)

一般来讲应用想获取内存信息,用的最多的就是通过Runtime来获取,可以通过它获取应用最大可用内存,当前分配的内存,当前空闲内存,已使用内存

infoText.text = checkMemoryRuntime()

private fun checkMemoryRuntime(): String {
    val runtime = Runtime.getRuntime()
    val maxMemory = runtime.maxMemory() // 应用最大可用内存
    val totalMemory = runtime.totalMemory() // 当前分配的内存
    val freeMemory = runtime.freeMemory() // 当前空闲内存
    val usedMemory = totalMemory - freeMemory // 已使用内存
    return "最大可用内存:${maxMemory} 当前分配的内存:${totalMemory} 当前空闲内存:${freeMemory} 已使用内存${usedMemory}"
}

获取内存(MemoryInfo)

还有一种方式就是通过获取MemoryInfo来拿到内存信息,比如总内存,当前空闲内存以及判断内存是否过低.

infoText.text = checkMemoryMemoInfo()

private fun checkMemoryMemoInfo(): String {
    val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
    val memoryInfo = ActivityManager.MemoryInfo()
    activityManager.getMemoryInfo(memoryInfo)
    val totalMem = memoryInfo.totalMem
    val availMem = memoryInfo.availMem
    val lowMemory = memoryInfo.lowMemory
    return "总内存:${totalMem} 当前空闲内存:${availMem} 内存是否过低${lowMemory}"
}

磁盘空间(外部存储与内部存储)

可以通过StatFs来获取外部存储以及内存存储容量

//外部存储
infoText.text = checkExternalStorageInfo()

private fun checkExternalStorageInfo(): String {
    if (Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED) {
        val path = Environment.getExternalStorageDirectory() // 外部存储根目录
        val stat = StatFs(path.path)
        val blockSize = stat.blockSizeLong
        val totalBlocks = stat.blockCountLong
        val availableBlocks = stat.availableBlocksLong
        val totalSize = blockSize * totalBlocks
        val availableSize = blockSize * availableBlocks
        val usedSize = totalSize - availableSize
        return "外部存储总容量:${totalSize} 外部存储可用容量:${availableSize} 外部存储已用容量:${usedSize}"
    }
    return ""
}
//内部存储
infoText.text = checkInternalStorageInfo()

private fun checkInternalStorageInfo(): String {
    val path = Environment.getDataDirectory() // 内部存储根目录
    val stat = StatFs(path.path)
    val blockSize = stat.blockSizeLong // 每个block的大小
    val totalBlocks = stat.blockCountLong // 总block数
    val availableBlocks = stat.availableBlocksLong // 可用block数

    val totalSize = blockSize * totalBlocks // 总容量
    val availableSize = blockSize * availableBlocks // 可用容量
    val usedSize = totalSize - availableSize // 已用容量
    return "内部存储总容量:${totalSize} 内部存储可用容量:${availableSize} 内部存储已用容量:${usedSize}"
}

CPU内核数量

获取CPU的内核数量很简单,Runtime类中有现成的方法

infoText.text = checkCPUcoreNumber()

private fun checkCPUcoreNumber() = 
     "cpu核心数 : ${Runtime.getRuntime().availableProcessors()}"

CPU架构

infoText.text = checkCPUArchitecture()

private fun checkCPUArchitecture() =
     "cpu架构:${Build.SUPPORTED_ABIS[0]}"

CPU最大频率

fun getCpuFreq(): String? {
    try {
        val reader =
            BufferedReader(FileReader("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq"))
        val freq = reader.readLine()
        reader.close()
        // 将频率从 kHz 转换为 MHz
        return "${(freq.toLong() / 1000)} MHz"
    } catch (e: IOException) {
        e.printStackTrace()
    }
    return null
}

CPU硬件信息

infoText.text = checkCPUHardware()
private fun checkCPUHardware() = 
        "硬件信息:${Build.HARDWARE}"

检测设备是否root

同样的没有任何api可以直接去判断设备是否有root权限,我们只能从以下几个方式去判断: 判断检查是否存在相关root文件

var fileRooted = false
val paths = arrayOf(
    "/system/app/Superuser.apk",
    "/sbin/su",
    "/system/bin/su",
    "/system/xbin/su",
    "/data/local/xbin/su",
    "/data/local/bin/su",
    "/system/sd/xbin/su",
    "/system/bin/failsafe/su",
    "/data/local/su",
    "/su/bin/su"
)
for (path in paths) {
    if (File(path).exists()) {
        fileRooted = true
    }
}

检查是否存在su命令

var suCmdExest = false
var process: Process? = null
try {
    process = Runtime.getRuntime().exec(arrayOf("which", "su"))
    val reader = BufferedReader(InputStreamReader(process.inputStream))
    suCmdExest = reader.readLine() != null
} catch (e: Exception) {
    suCmdExest = false
} finally {
    process?.destroy()
}

检查 Build.TAGS 里面是否存在 test-keys

```kotlinvar testKeys = false val buildTags = Build.TAGS testKeys = buildTags != null && buildTags.contains(“test-keys”)


**执行su命令**

```kotlin
var suCmdExecute = false
var suprocess: Process? = null
try {
    suprocess = Runtime.getRuntime().exec("su")
    val out = suprocess.outputStream
    out.write("exit\n".toByteArray())
    out.flush()
    out.close()
    suCmdExecute = suprocess.waitFor() == 0
} catch (e: java.lang.Exception) {
    suCmdExecute = false
} finally {
    suprocess?.destroy()
}

Magisk 文件是否存在

var giskFile = false
val magiskPaths = arrayOf(
    "/sbin/.magisk",
    "/sbin/magisk",
    "/cache/.disable_magisk",
    "/cache/magisk.log",
    "/data/adb/magisk",
    "/data/adb/modules",
    "/data/magisk",
    "/data/magisk.img"
)

for (path in magiskPaths) {
    if (File(path).exists()) {
        giskFile = true
    }
}

所有条件综合判断:

val gotRoot = fileRooted || suCmdExest || testKeys || suCmdExecute || giskFile

网络情况

这个也是在应用当中经常会用到的一个属性,判断设备是连接的是wifi,还是连接的是2,3,4,5G网络,首先通过获取 NetworkCapabilities 来判断是否连接的是wifi还是移动网络。

private fun checkNetworkType(): String {
    var net = ""
    val cManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
    val capabilities: NetworkCapabilities? =
        cManager.getNetworkCapabilities(cManager.activeNetwork)
    capabilities?.let { cb ->
        if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
            net = "WIFI"
        } else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
            net = getMobileNetworkType(cManager)
        }
    }
    return "网络情况:${net}"
}

当判断出非wifi网络的时候,再通过getMobileNetworkType函数来得出具体的网络类型

private fun getMobileNetworkType(cManager: ConnectivityManager): String {
    val networkInfo = cManager.getNetworkInfo(ConnectivityManager.TYPE_MOBILE)
    if (networkInfo != null) {
        val networkType = networkInfo.subtype
        return when (networkType) {
            TelephonyManager.NETWORK_TYPE_GPRS, TelephonyManager.NETWORK_TYPE_EDGE -> "2G"
            TelephonyManager.NETWORK_TYPE_UMTS, TelephonyManager.NETWORK_TYPE_HSPA -> "3G"
            TelephonyManager.NETWORK_TYPE_LTE -> "4G"
            TelephonyManager.NETWORK_TYPE_NR -> "5G"
            else -> "UNKNOWN"
        }
    }
    return "UNKNOWN"
}

【Android进阶】车载通信架构——SOA

【Android进阶】车载通信架构——SOA

本文介绍了车载通信架构中的 SOA(Service Oriented Architecture,面向服务的架构)通信协议相关内容。

服务导向架构(Service-Oriented Architecture,SOA)是一种软件设计方法论,它强调将应用程序构建为一系列松散耦合(loosely coupled)可独立部署(independently deployable) 且通过 定义良好的接口(well-defined APIs) 进行通信的 服务(services)

SOA 架构简介

在 SOA 中,每个服务都执行一个特定的业务功能,并且可以被其他服务或应用程序调用和重用。它不是一种技术,而是一种架构风格,可以通过各种技术(如 SOAP、REST、消息队列等)实现。

核心概念:

  • 服务(Service):SOA 的基本构建单元。服务是自包含的、模块化的、并且执行特定业务功能的软件组件。它们对外提供接口,隐藏内部实现细节。
  • 松散耦合(Loose Coupling):服务之间相互独立,彼此之间的依赖性很小。这意味着一个服务的改变不会直接影响到其他服务,从而提高了系统的灵活性和可维护性。
  • 接口(Interface):服务通过明确定义的接口进行通信。这些接口是公共的、标准化的,允许不同技术栈的服务之间进行交互。
  • 可重用性(Reusability):由于服务是独立的且功能明确,它们可以在不同的应用程序和业务流程中被重复使用,减少了重复开发的工作。
  • 互操作性(Interoperability):服务可以跨越不同的平台、编程语言和操作系统进行通信,因为它们依赖于标准化的通信协议和接口。

SOA 的优势:

  • 模块化和可重用性:便于开发、测试、部署和维护新功能,并与其他系统集成。
  • 可扩展性:可以独立扩展单个服务,以满足不断增长的需求,而无需扩展整个系统。
  • 灵活性和敏捷性:更容易适应业务需求的变化,快速响应市场。
  • 易于维护:问题定位和修复更简单,降低了维护成本。
  • 异构系统集成:能够整合不同技术和平台构建的系统。

SOA 在车载领域的应用

传统的汽车电子电气(E/E)架构是高度分散的,由数百个独立的电子控制单元(ECU)组成,每个 ECU 负责一个或几个特定功能(如发动机控制、车窗控制、信息娱乐等),它们之间通过 CAN、LIN 等低速总线进行通信。这种单片式(monolithic)信号导向(signal-oriented)的架构随着车辆功能(如自动驾驶、智能座舱、车联网)的爆炸式增长,暴露出以下局限性:

  • 复杂性高:ECU 数量过多,布线复杂,系统集成困难。
  • 扩展性差:添加新功能需要修改多个 ECU 的软件,甚至需要重新设计硬件。
  • 维护困难:诊断和更新难度大,OTA(Over-The-Air)升级受限。
  • 数据孤岛:不同 ECU 之间的数据共享不便。

为了应对这些挑战,汽车行业正在向软件定义汽车(Software-Defined Vehicle, SDV)的方向发展,而 SOA 正是实现 SDV 的关键使能技术。

1. 架构演进:从分布式到域控/区域控制,再到中央计算

  • 域控制器(Domain Controller):将传统上分散的 ECU 整合到几个大的域控制器中(如智能座舱域、自动驾驶域、车身控制域等)。
  • 区域控制器(Zonal Controller):进一步将功能聚合,将车辆划分为几个物理区域,每个区域有一个区域控制器处理该区域内的传感器、执行器和通信。
  • 中央计算平台(Central Computing Platform):最终目标是将大部分计算能力集中到一个或少数几个高性能计算单元中,形成车载“大脑”,负责处理跨域的复杂功能。

在这些新的 E/E 架构中,高速以太网(Ethernet)逐渐取代传统的总线作为主要通信骨干网,为 SOA 提供必要的带宽和低延迟。相比于分布式架构,会有一个处理器作为 中央网关 ,来综合管理其他各个服务的订阅,数据分发。

2. SOA 如何应用于车载系统

在车载 SOA 中,车辆的各项功能被抽象为可独立调用和组合的“服务”。例如:

  • 传感器数据服务:提供来自摄像头、雷达、激光雷达等传感器的原始或处理后的数据。
  • 执行器控制服务:例如“制动服务”、“转向服务”、“车窗控制服务”等。
  • 诊断服务:提供车辆健康状态、故障码等信息。
  • 信息娱乐服务:如导航、媒体播放、语音识别等。
  • 互联服务:与外部云端、移动设备通信的服务。

SOME/IP协议

SOME/IP,全称 Scalable Service-Oriented Middleware over IP (基于 IP 的可伸缩面向服务中间件),它是一种专为汽车域设计的高效通信协议。

起源与目的: 随着现代汽车电子架构的复杂性日益增加,传统的车载网络协议(如 CAN、LIN、FlexRay)在 带宽、灵活性和可伸缩性 方面逐渐遇到瓶颈。为了满足信息娱乐、高级驾驶辅助系统 (ADAS) 等对带宽和实时性要求更高的新功能,汽车行业开始引入以太网。SOME/IP 应运而生,旨在为基于 IP 的汽车网络提供一种面向服务的通信机制。

AUTOSAR 整合: SOME/IP 是 AUTOSAR (AUTomotive Open System ARchitecture) 标准的关键组成部分。AUTOSAR 致力于统一汽车软件架构,SOME/IP 作为其通信层的一部分,确保了不同供应商和制造商之间的软件组件兼容性和互操作性。

服务发现(Service Discovery)

服务发现的通信机制是通过SOME/IP-SD协议实现的,主要是为了实现在车载以太网中告知客户端当前服务实例的可用性及访问方式,可通过Find Service 和Offer Service来实现。

SOME/IP 服务发现流程可以分为以下三大基本步骤:

  • Client通过发送Find Service的报文去寻找车载网络中可用的服务实例;
  • Server接收到Client的Find Server后通过UDP发送Offer Service响应;
  • Client通过发送Subcribe Event Group去订阅相关Event;
  • Server检查是否满足Client是否满足订阅条件,如果满足回复ACK,如果不满足,则回复NACK;
  • Client成功订阅相关事件后,Server会按照事件本身属性来实现对已订阅该事件的Client的发布;

远程进程调用(RPC)

远程进程调用主要可分为四种通信模式:

1. Request/Response通信模式

Request-Response模型作为一种最为常见的通信方式,其主要任务就是客户端发送请求信息,服务端接收到请求,进行相关处理之后进行相应的响应。

2. Fire&Forget通信模式

该通信模型的主要任务就是客户端向服务端发送请求,服务端无需进行任何响应,有点类似诊断服务中的抑制正响应。

3. Notification Event通信模式

该通信模式主要描述了发布 /订阅消息内容,主要任务就是为了实现客户端向服务端订阅相关的事件组,当服务端的事件组发生或者值发生变化时,就需要向已订阅该事件组的客户端发布更新的内容。

4. 远程进程控制(Field)

访问进程通信机制主要是为了实现针对对应用程序的数据获取与更改,主要任务就是实现客户端通过Getter获取Server的值,通过Setter设置Server的值。

Field就可理解为一个Service的基本属性,可包含Getter,Setter,Notifier三种方式。其中Getter就是读取Field中某个值的方法,Setter就是一种改变Field值的方法,而Notifier则是一种当Field中的值发生变化的触发事件,发生变化时就通知Client。

错误处理机制

AUTOSAR为了更为高效的定位到通讯过程中的问题所在,制定了一套检查SOME/IP协议格式内容的错误处理机制。比如版本信息检查,服务ID等,其他故障信息可以在Payload中进行详细定义。目前SOME/IP支持以下两种错误处理机制,这两种uowu处理机制可以根据配置进行选择。

  • 消息类型0x80,Response信息,即可以通过Response Message中的Return Code来定位到问题所在;

  • 消息类型0x81,显式的错误信息;

主要是校验协议首部结构,对Message Type和Return code进行赋值,通知对端。

【Android进阶】车载通信架构——CAN分布式

【Android进阶】车载通信架构——CAN分布式

本文介绍了车载通信架构中的CAN(Controller Area Network)通信协议相关内容。

车载通信中的CAN(Controller Area Network,控制器局域网)协议是汽车电子系统中应用最广泛的串行通信协议之一,一种专门为恶劣环境设计的串行通信协议。它的老家是德国,由博世公司在1986年正式发布,后来被写进了ISO11898-1标准,定义了OSI模型的数据链路层和物理层。

其设计初衷是为了解决汽车内部日益复杂的电子控制单元(ECU)之间的通信需求,替代传统的点对点布线方式,以减少线束重量、降低成本并提高通信可靠性。

现在,它已广泛应用于汽车、工业自动化、医疗设备等领域,作为一种高效、可靠的通信方式。

一种典型架构

CAN架构

CAN 总线是一种消息导向(message-based)的通信协议,而不是地址导向(address-based)。这意味着总线上的所有设备(称为节点ECU - Electronic Control Unit)都能“听到”所有传输的消息,每个消息都包含一个标识符(ID),而不是一个目标地址。节点会根据这个 ID 来决定是否接收和处理该消息。

核心设计目标

CAN的诞生初衷是为了解决汽车内部电子控制单元(ECU)之间通信的麻烦。以前,ECU之间需要一大堆线缆连接,布线复杂得像蜘蛛网。CAN总线的出现让这一切变得简单:只需一对差分信号线,就能让所有ECU愉快地聊天。

CAN总线有以下几个特点:

  1. 多主站(Multi-master):网络中没有中心控制器,所有节点(ECU)都可以在总线空闲时尝试发送消息,适合分布式电子架构
  2. 广播(Broadcast):所有发送的消息都会被网络中的所有节点接收。
  3. 优先级仲裁(Arbitration):当多个节点同时尝试发送消息时,CAN 总线通过一个基于消息 ID 的仲裁机制来解决冲突。ID 值越低,优先级越高,拥有更高优先级的消息会赢得总线,而低优先级的消息会暂停发送,等待总线空闲后重试。
  4. 高可靠性和容错性(High Reliability and Fault Tolerance):CAN 总线内置了强大的错误检测和错误处理机制,例如循环冗余校验(CRC)、位填充(Bit Stuffing)和应答(ACK),确保数据传输的完整性。即使出现错误,系统也能进行错误隔离和恢复。
  5. 低成本:简化线束设计(两根信号线即可连接所有节点),降低整车布线复杂度与成本。
  6. 差分信号传输(Differential Signalling):CAN 总线通过两根双绞线(CAN_H 和 CAN_L)传输差分信号,这有助于抵御电磁干扰(EMI),使其在嘈杂的环境中也能可靠工作。
  7. 终端电阻(Termination Resistors):CAN 总线两端需要各放置一个 120 欧姆的终端电阻,以消除信号反射,保证信号完整性。
  8. 实时性:通过优先级仲裁机制确保关键信息(如制动、安全气囊控制)优先传输。

CAN协议的物理层与数据链路层

CAN协议分为物理层数据链路层(逻辑链路控制子层LLC和介质访问控制子层MAC),其中物理层和MAC子层由CAN标准(ISO 11898)定义,LLC子层由用户自定义。

物理层

  • 信号传输介质:通常采用双绞线(CAN_H和CAN_L),通过差分信号传输(平衡传输),抗干扰能力强。
  • 信号电平
    • 显性电平(Dominant):CAN_H电压高于CAN_L(典型值:CAN_H=3.5V,CAN_L=1.5V),逻辑“0”。
    • 隐性电平(Recessive):CAN_H与CAN_L电压相等(约2.5V),逻辑“1”。
    • 优先级规则:显性电平可覆盖隐性电平(类似“线与”逻辑),用于仲裁。
  • 通信速率:支持多种速率(如125kbps、250kbps、500kbps、1Mbps等),速率越高,通信距离越短(例如1Mbps时最大距离约40米,500kbps时约100米)。

数据链路层

一个标准CAN消息帧包含以下几个关键部分:

  • 帧起始(SOF):一个显性位,标志着消息的开始,相当于敲门声,提醒大家有新消息来了。
  • 标识符(ID):11位长度,决定了消息的优先级。ID越小,优先级越高。这就像在群聊里,谁的ID靠前,谁的消息就先被处理。
  • 远程传输请求(RTR):通常是显性位,但当某个节点想请求数据时,会变成隐性位。
  • 标识符扩展(IDE):显性位表示这是标准CAN帧,不是扩展帧。
  • 数据长度码(DLC):4位,告诉接收方这次消息带了多少字节数据。
  • 数据字段:实际要传的内容,最多8字节。比如发动机转速、油门开度啥的。
  • 循环冗余校验(CRC):16位校验码,用来检测传输错误,堪称数据的保镖。
  • 应答位(ACK):接收方如果正确收到消息,会把这个隐性位覆盖为显性位,相当于说:收到,靠谱!
  • 帧结束(EOF):7位隐性位,用于标记消息结束,同时检测是否有位填充错误。
  • 帧间间隔(IFS):一段空闲时间,让CAN控制器有空把收到的消息塞进缓冲区。

后来,CAN升级了,推出了扩展CAN,把标识符从11位扩展到29位,消息ID数量暴增到2的29次方,满足更复杂的应用场景。扩展帧在11位ID后加了个替代远程请求(SRR)位,IDE位变成隐性,表示后面还有18位ID。其他部分和标准帧差不多。

消息类型

CAN总线支持四种消息类型:

  1. 数据帧(Data Frame):用于传输实际数据。这是最常见的帧类型。RTR和IDE都是显性位。
  2. 远程帧(Remote Frame):用于请求其他节点发送特定 ID 的数据帧。不带数据,RTR为隐性,用于请求某个节点发送数据,相当于喊一嗓子:兄弟,发个数据包过来!
  3. 错误帧(Error Frame):当节点检测到错误时发送,通知网络其他节点有错误发生,然后出错的节点会重发消息。
  4. 过载帧(Overload Frame):用于通知总线上当前负载过高,请求短暂延迟。当某个节点忙不过来,处理不过收到的帧时,会发个过载帧,争取点喘息时间。

CAN协议的关键机制

多主仲裁机制

简单来说,就是解决多个节点同时想发消息时的优先级问题。

  • 所有节点通过总线竞争发送数据时,ID数值越小的帧优先级越高(例如ID=0x100的帧优先于ID=0x200的帧)。
  • 仲裁过程:节点同时发送数据时,逐位比较电平。若某节点发送隐性电平(1),而总线上出现显性电平(0),则该节点主动退出竞争,转为接收状态。

错误检测与处理

  • 错误类型:位错误(发送与接收电平不一致)、填充错误(连续5个相同电平未插入相反电平)、CRC错误、格式错误、ACK错误等。
  • 错误处理:节点检测到错误后发送错误帧,通知所有节点丢弃当前帧;错误计数器记录错误次数,超过阈值(如127次)的节点进入“被动错误状态”(仅能发送被动错误帧),严重错误时进入“总线关闭状态”(暂时退出通信)。

数据重传机制

  • 若发送方未收到ACK确认(可能因接收方故障或总线冲突),会自动重传数据帧(最多重传多次,具体次数由实现决定)。

CAN协议的扩展版本

为满足更高数据传输需求,CAN协议衍生出多个扩展版本:

  1. CAN FD(Flexible Data-Rate)
    • 核心改进:数据段速率可提升至5Mbps(仲裁段仍保持较低速率),数据场长度扩展至64字节(标准CAN仅8字节)。
    • 应用场景:适用于需要传输大量数据的应用(如高级驾驶辅助系统ADAS、摄像头数据传输)。
  2. CAN XL
    • 更高带宽:支持更高速率(如10Mbps以上)和更大 payload(最高2048字节),面向未来智能汽车的高带宽需求。

CAN协议在车载网络中的应用

CAN总线在汽车电子和工业控制领域简直无处不在。

比如新能源汽车的BMS(电池管理系统),通过CAN总线实时监控电池状态,SOC、SOH、温度、电压等数据飞速在ECU间传递。

工业领域,像是Modbus或DeviceNet这样的协议,底层也靠CAN总线撑腰。

相比传统的点对点连接,CAN总线的多主架构让系统扩展性强到爆,甚至随手加个节点都随随便便。

想象一下现代汽车,发动机控制、ABS、仪表盘、空调系统……每个模块都是一个ECU,它们通过CAN总线组成一个高效的通信网络。

就像一群人在群聊里实时交流,消息井然有序,互不干扰。比如你踩油门,发动机ECU立马收到指令,调整喷油量,整个过程非常顺畅。

  1. 典型应用场景
    • 动力系统:发动机控制、变速箱控制(需高实时性)。
    • 底盘系统:ABS防抱死、ESP电子稳定程序。
    • 车身电子:车窗控制、灯光控制、空调系统。
    • 安全系统:安全气囊、碰撞检测。
  2. 与其他车载网络的协同
    • CAN通常作为整车通信的骨干网络,与LIN(低速、低成本,用于车窗等简单设备)、FlexRay(高实时性、高带宽,用于底盘控制)、以太网(大数据传输,如自动驾驶传感器数据)协同工作,形成分层网络架构。

CAN协议凭借其高可靠性、实时性和低成本优势,成为汽车电子通信的基石。随着汽车智能化与电动化的发展,CAN FD和CAN XL等扩展版本进一步提升了带宽与数据处理能力,而CAN与其他车载网络(如以太网)的协同也将成为未来车载通信架构的核心趋势。

如何分析 CAN 总线通信协议

分析 CAN 总线通信通常涉及到捕获总线上的原始数据,然后对这些数据进行解码和解释,以理解各个消息的含义。对于 Android 开发者来说,这可能涉及到通过蓝牙 OBD-II 适配器与车辆 CAN 总线交互,或者在嵌入式系统中直接与 CAN 控制器通信。

1. 硬件工具准备:

  • CAN 总线分析仪/接口卡:这是最核心的工具。它们可以将 CAN 总线上的物理信号转换为计算机可以理解的数字信号。常见的有:
    • USB 转 CAN 接口:如 PCAN-USB, Kvaser USBcan, IXXAT USB-to-CAN 等。这些通常用于连接到 PC 进行分析。
    • OBD-II 转 CAN 适配器:对于汽车应用,许多 OBD-II 适配器(例如 ELM327 兼容的蓝牙或 Wi-Fi 适配器)可以让你通过手机或电脑访问车辆的 CAN 总线数据,但功能可能受限。
  • 连接线缆:连接 CAN 分析仪和 CAN 总线(通常是 OBD-II 端口或直接连接到 CAN_H/CAN_L)。
  • 带有合适软件的计算机:用于捕获、显示和解析 CAN 数据。

2. 软件工具准备:

  • CAN 总线监测软件:大多数 CAN 接口卡都附带自己的软件,例如 PCAN-View, Kvaser CanKing 等。这些软件可以实时显示总线上的消息,包括 ID、数据、时间戳等。
  • CAN 数据库文件(DBC 文件):这是分析 CAN 数据的关键。DBC 文件是一种标准格式,它包含了 CAN 消息的定义,例如:
    • 每个 CAN ID 对应的消息名称。
    • 每个消息中各个信号(Sensor Readings, Status, Commands)的起始位、长度、字节顺序(大小端)、缩放因子、偏移量和单位。
    • 信号的有效值范围。
    • 枚举值(例如,某个字节代表“开”或“关”)。 有了 DBC 文件,原始的十六进制数据就能被解析成有意义的物理值,例如发动机转速、车速、油门位置等。
  • 数据分析工具:如果需要更深入的分析,可以使用 MATLAB/Simulink, Python (使用 python-can 库), Wireshark (结合 CAN 插件) 等工具进行数据处理、可视化和模式识别。

3. 分析步骤:

  1. 连接硬件:将 CAN 分析仪连接到目标 CAN 总线。对于汽车,通常是连接到 OBD-II 端口。确保总线已上电。
  2. 配置软件
    • 选择正确的 CAN 接口:在软件中选择你连接的 CAN 接口设备。
    • 设置波特率(Baud Rate):CAN 总线上的所有设备必须以相同的波特率通信(例如 500 kbps, 250 kbps)。你需要根据目标系统设置正确的波特率。许多分析仪支持自动检测。
    • 设置过滤(Filtering):如果你只对特定 ID 的消息感兴趣,可以设置 ID 过滤器,以减少显示的数据量。
  3. 捕获数据:开始捕获 CAN 总线上的数据。你会看到一系列十六进制的 CAN 帧。
    • 观察总线活动:注意哪些 ID 的消息频繁出现,哪些消息在特定操作下(例如踩油门、刹车)会发生变化。
    • 触发特定事件:在分析车辆 CAN 总线时,执行特定的操作(如打开车窗、踩刹车、启动引擎等),然后观察哪些 CAN 消息随之变化。这有助于你找到与这些操作相关的消息 ID 和数据。
  4. 解码数据(使用 DBC 文件)
    • 导入 DBC 文件:将对应的 DBC 文件导入你的 CAN 分析软件。
    • 实时解析:软件会根据 DBC 文件的定义,将原始的十六进制数据自动解析成有意义的信号值(例如,从 0x1A0 ID 的消息中解析出“发动机转速”为 2500 RPM)。
    • 手动解码(如果无 DBC):如果没有 DBC 文件,你需要进行逆向工程。这通常是一个耗时且需要经验的过程:
      • 隔离消息:通过筛选和观察,找到你感兴趣的特定功能(如车速、门状态)对应的 CAN ID。
      • 分析数据变化:对特定 ID 的数据字段进行多次捕获,每次都改变对应的物理量(例如,让车速从 0 加速到 100 km/h,观察数据字段的十六进制值如何变化)。
      • 推断数据格式:根据数据变化的规律,推断出数据的字节顺序、长度、缩放因子、偏移量。这可能需要了解一些常见的编码方式,如 little-endian/big-endian,有符号/无符号整数,浮点数等。
      • 创建自己的 DBC 文件:当你识别出一些信号后,可以创建自己的 DBC 文件来记录这些发现,方便后续分析和开发。
  5. 数据分析与应用
    • 数据可视化:将解析出的信号数据绘制成图表,更直观地观察其变化趋势。
    • 故障诊断:通过分析 CAN 消息,可以识别通信故障、传感器故障或 ECU 内部问题。
    • 功能开发:对于 Android 开发者,理解 CAN 消息后,你可以在应用程序中读取车辆数据(例如仪表盘信息、故障码),或发送特定指令来控制车辆功能(如果允许且安全)。

CAN协议的优缺点

  • 优点
    • 高可靠性:抗干扰能力强,错误检测与恢复机制完善。
    • 实时性强:优先级仲裁确保关键信息优先传输。
    • 成本低:简化线束设计,降低整车成本。
    • 成熟生态:广泛支持的工具链和芯片(如NXP、Infineon的CAN控制器)。
  • 缺点
    • 带宽有限:标准CAN最大速率1Mbps,数据场仅8字节,难以满足高清摄像头等大数据传输需求(需CAN FD或以太网补充)。
    • 无内置加密:需额外机制(如SecOC)保障信息安全(针对新能源车的V2X通信需求)。

下一篇将介绍基于中央服务的SOA架构,和CAN总线的分布式架构做对比。

【Android进阶】关于Java参数传递的小测试

【Android进阶】关于Java参数传递的小测试

本文介绍了Java方法参数传递过程中的一些规则,理清了一些流程

很早之前就了解到,Java方法传递参数是值传递,不是引用传递。

C++ 中,我们可以把方法的参数设置为外部变量的引用,就可以直接通过这个引用操作外部变量。例如C++的函数参数,如果在合适的时机,按引用传递,可以省去变量复制的步骤,优化性能。

#include <iostream>
using namespace std;

void GetSquare(int& number)
{
   number *= number;
}

int main()
{
   cout << "Enter a number you wish to square: ";
   int number = 0;
   cin >> number;

   GetSquare(number);
   cout << "Square is: " << number << endl;

   return 0;
}

Java是没有这样的机制的。

一、Java 是值传递,不是引用传递

首先明确一点:

Java 中所有的参数传递都是值传递(pass by value),没有引用传递(pass by reference)。

这句话还有两个扩展结论:

  • 当你传递一个基本类型(如 int, boolean 等)给方法时,传递的是它的值的副本。
  • 当你传递一个对象(如 String, Activity 等)给方法时,传递的是该对象的引用的副本,而不是对象本身的副本。

举个例子:

void modifyObject(MyObject obj) {
    obj.setValue(100);  // 修改的是原对象的内容
    obj = new MyObject(); // 修改的是局部变量 obj 的引用,不影响外部
}

MyObject myObj = new MyObject();
modifyObject(myObj);
// myObj 指向的对象被修改了,但 myObj 本身还是原来的引用

在这个例子中:

  • objmyObj 引用的一个副本,它们指向同一个对象。
  • 所以通过 obj.setValue(100) 可以修改原对象的内容。
  • 但是 obj = new MyObject() 只是让局部变量 obj 指向了一个新对象,不会影响外部的 myObj

二、 使用Activity 作为参数

如果是按照值的副本传递,那么Activity对象被当作参数,传递给外部方法并引用,在Activity销毁时是不会产生泄露现象的,正式因为传递的是引用的副本,所以这个引用关系仍然存在。

在 Android 中,当把一个 Activity 作为参数传递给某个方法时,似乎没有复制一个新的 Activity 对象,而是直接操作了原来的 Activity

正是 Java 值传递的表现

具体解释:

假设你有如下代码:

startSomeProcess(MainActivity.this);

这里的 MainActivity.this 是当前 Activity 的引用(即指向 Activity 对象的一个指针)。当你把这个引用作为参数传递给方法时:

void startSomeProcess(Activity activity) {
    activity.setTitle("New Title"); // 修改的是原 Activity 的标题
    activity = new Activity();     // 这里只是修改了局部变量 activity 的指向
}
  • activityMainActivity.this 引用的一个副本(即引用的值被复制了一份)。
  • 所以 activity.setTitle("New Title") 修改的是原来的 Activity 对象。
  • 但是 activity = new Activity() 只是让方法内部的局部变量 activity 指向了一个新的 Activity 对象,不会影响外部的 MainActivity.this

这完全符合 Java 的值传递机制。但是下面几点需要注意:

1. Activity 是一个重量级对象,通常不应该作为方法参数频繁传递

  • Activity 本身包含大量状态信息、视图层次结构、生命周期管理等。
  • Activity 作为参数传递,尤其是跨组件传递(如从 Fragment 传递到工具类、Service 等),是一种不好的实践,可能导致内存泄漏或逻辑混乱。

2. Activity 持有 Context,而 Context 是与生命周期强相关的

  • 如果你在一个长生命周期对象(如单例、静态变量、Service 等)中持有 Activity 的引用,可能会导致 Activity 无法被回收,从而引发内存泄漏。
  • 这也是为什么 Android Lint 会对 static 字段持有 Activity 发出警告(StaticFieldLeak)。

正确做法建议

尽量避免直接传递 Activity 对象,而是通过接口、回调或者 Context(非 Activity 类型)来解耦。

使用 Application Context 替代 Activity Context,比如加载资源、启动 Service 等操作可以使用 getApplicationContext(),避免持有 Activity

必要时,使用弱引用(WeakReference)来持有 Activity。如果确实需要在某个长生命周期对象中引用 Activity,可以使用 WeakReference<Activity>,这样即使 Activity 被销毁,也不会阻止垃圾回收。

示例:

private WeakReference<Activity> activityRef;

public void setActivity(Activity activity) {
    this.activityRef = new WeakReference<>(activity);
}

public void doSomething() {
    Activity activity = activityRef.get();
    if (activity != null && !activity.isFinishing()) {
        activity.setTitle("Safe Title Change");
    }
}

【Android基础】AccessibilityService 无障碍服务使用

【Android基础】AccessibilityService 无障碍服务使用

本文介绍了AccessibilityService的使用方法

AccessibilityService 是 Android 系统提供的一种特殊类型的服务,它允许应用程序监听系统和应用中的各种事件,并与用户界面(UI)进行交互。

它最初设计目的是为了帮助有视觉、听觉或运动障碍的用户更有效地使用 Android 设备。例如,它可以朗读屏幕上的内容、响应特定的手势或提供自定义的导航。

但由于其强大的能力,它也被广泛用于实现自动化任务屏幕内容监控手势模拟等高级功能。

核心能力和工作原理

继承自 AccessibilityService ,实现的服务中使用最多的有以下核心功能:

1. 监听事件

它可以监听系统和应用发出的各种可访问性事件(AccessibilityEvent),这些事件包括:

  • 窗口状态变化: 例如,新窗口打开、关闭或聚焦变化。
  • 视图内容变化: 例如,文本框中的文字被修改、列表中的项目被添加。
  • 焦点变化: 当用户或程序将焦点移动到不同的 UI 元素时。
  • 通知栏变化: 接收和处理通知栏的发布、更新和移除事件。

2. 访问屏幕内容

服务可以获取到屏幕上当前活动窗口的 视图层次结构(AccessibilityNodeInfo)。通过这个节点信息,服务可以获取任何可见 TextView、EditText 或其他可访问元素上的文本内容。还能通过资源 ID(例如 com.example.app:id/button_ok)定位特定的 UI 元素。

3. 模拟用户操作

这是 AccessibilityService 最强大的功能之一,它允许服务代替用户执行操作,实现自动化:

  • 点击和长按: 模拟点击任何可点击的 UI 元素。
  • 输入文本: 填充 EditText 字段。
  • 滚动: 向上、向下、向左或向右滚动可滚动的视图。
  • 执行全局操作: 例如,返回(BACK)、主页(HOME)、打开最近任务列表或显示通知栏。
  • 模拟手势: 在屏幕的任意坐标上模拟复杂的触摸手势,如滑动(Swipe)。

开发实践

以一个自动跳广告的Demo为例,展示如何使用 AccessibilityService 实现自动化任务。

创建服务类

首先定义服务类 AutoSkipAdsService ,继承自 AccessibilityService

class AutoSkipAdsService : AccessibilityService()

这时候会自动要求实现以下方法:

override fun onAccessibilityEvent(event: AccessibilityEvent) {
    // 处理事件,例如查找广告并点击跳过按钮
}

override fun onInterrupt() {
    // 服务被中断时回调,例如用户关闭了服务
}

一般来说,还需要在 onCreate() 中设置为前台服务,以提示用户服务正在运行。

override fun onCreate() {
    super.onCreate()
    // 创建前台通知,提示用户服务正在运行
    val notification = NotificationCompat.Builder(this, CHANNEL_ID)
        .setContentTitle(getString(R.string.accessibility_service_title))
        .setContentText(getString(R.string.accessibility_service_description))
        .setSmallIcon(R.drawable.ic_accessibility)
        .build()
    // 启动前台服务,显示通知
    startForeground(NOTIFICATION_ID, notification)
}

配置 Manifest 声明

在应用的 AndroidManifest.xml 文件中声明 AccessibilityService

<service
    android:description="@string/description_in_manifest"
    android:exported="true"
    android:foregroundServiceType="mediaPlayback"
    android:label="自动跳过广告"
    android:name=".service.AutoSkipAdsService"
    android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
    tools:ignore="ForegroundServicePermission">
    <intent-filter>
        <action android:name="android.accessibilityservice.AccessibilityService" />
    </intent-filter>

    <meta-data
        android:name="android.accessibilityservice"
        android:resource="@xml/accessibility_config" />
</service>

其中最重要的配置就是 accessibility_config.xml ,它定义了服务的行为和关注的事件类型。关键的配置项:

属性描述
accessibilityEventTypes服务的关注事件类型(例如:typeAlltypeViewClicked)。
accessibilityFeedbackType服务提供的反馈类型(例如:feedbackGeneric 用于自动化)。
canRetrieveWindowContent设置为 true 才能访问窗口内容(读取屏幕信息)。
packageNames服务仅关注的应用包名列表。如果不设置,则监听所有应用。
canRequestTouchExploration是否请求触摸探索模式。

我的配置如下:

<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
    android:accessibilityEventTypes="typeAllMask"
    android:accessibilityFeedbackType="feedbackGeneric"
    android:canPerformGestures="true"
    android:canRetrieveWindowContent="true"
    android:description="@string/description_in_xml"
    android:notificationTimeout="100" />

实现逻辑

既然要跳广告,就需要在页面内容变化时,扫描页面上的广告元素。如果发现广告元素,就模拟点击 跳过 按钮。

AccessibilityService 整个作用域中,我们都可以获取到当前活动窗口的根节点 rootInActiveWindow ,它是一个 AccessibilityNodeInfo 对象,代表了当前活动窗口的视图层次结构。这个结构里包含了所有可见的 UI 元素,我们可以通过遍历这个结构,来查找广告元素和跳过按钮。

构建一个扩展方法 scanAndClickByText ,用于扫描页面上的文字元素,并点击指定的元素。在 onAccessibilityEvent() 回调方法中,我们可以调用这个扫描方法,来查找并点击广告跳过按钮。

/**
 * 扫描文字,点击扫描到的第index个,默认第一个
 */
fun AccessibilityService.scanAndClickByText(scanText: String, index: Int = 0) = try {
    infoLog("scanText:$scanText")
    rootInActiveWindow?.findAccessibilityNodeInfosByText(scanText)
        ?.get(index)?.apply {
            infoLog(this.text.toString())
            val rect = Rect()
            this.getBoundsInScreen(rect)
            val x = rect.centerX()
            val y = rect.centerY()
            infoLog("x:$x, y: $y")
            performClickByCoordinate(x.toFloat(), y.toFloat())
        }
} catch (e: Exception) {
    e.message?.let { errorLog(it) }
}

有时候软件提供商会规避自己的文字被辅助服务扫描到,这时候也可以根据控件id来识别元素。先找到广告元素的id,然后根据id来点击跳过按钮。

/**
 * 扫描控件id,点击扫描到的第index个,默认第一个
 */
fun AccessibilityService.scanAndClickById(viewId: String, index: Int = 0) = try {
    infoLog("scanViewId:$viewId")
    rootInActiveWindow?.findAccessibilityNodeInfosByViewId(viewId)
        ?.get(index)?.apply {
            infoLog(this.text.toString())
            val rect = Rect()
            this.getBoundsInScreen(rect)
            val x = rect.centerX()
            val y = rect.centerY()
            infoLog("x:$x, y: $y")
            performClickByCoordinate(x.toFloat(), y.toFloat())
        }
} catch (e: Exception) {
    e.message?.let { errorLog(it) }
}

performClickByCoordinate 这个方法又是怎么实现点击的呢?这里要用到 GestureDescription 类。

GestureDescription 是 Android 7.0 (API 24) 及以上版本中,AccessibilityService 用来创建和执行复杂触摸手势的核心类。它取代了之前通过 sendMotionEvent 模拟点击的旧方法,提供了更强大、更灵活的途径来自动化手势操作。

不管是滑动还是点击,都可以用 GestureDescription 来实现。

/**
 * 创建滑动手势
 * 使用:第二第三参数均可为空
 * dispatchGesture(@NonNull GestureDescription gesture,
 *             @Nullable GestureResultCallback callback,
 *             @Nullable Handler handler)
 *
 */
fun AccessibilityService.startSwipeGesture(
    startX: Float,
    startY: Float,
    endX: Float,
    endY: Float,
    duration: Long = 500L,
    callback: GestureResultCallback? = null,
    handler: Handler? = null
) {
    val path = Path()
    path.moveTo(startX, startY)
    path.lineTo(endX, endY)
    val builder = GestureDescription.Builder()
    // 立即开始
    val startTime = 0L
    // 滑动持续时间(单位:毫秒)
    val duration = duration
    val stroke = GestureDescription.StrokeDescription(path, startTime, duration)
    builder.addStroke(stroke)
    // 分发滑动手势
    dispatchGesture(builder.build(), callback, handler)
}

要模拟点击的话,将 duration 持续时间这个参数设置比较短即可。

用户授权

在项目代码中按照普通服务的启动方式,不管是 startService() 还是 bindService() 都是无法启动辅助服务的。

由于 AccessibilityService 拥有极高的权限,他可以做的事情和用户手动操作的权限是相同的。用户必须在 设置 - 辅助功能 中找到定义的服务祝福,并手动确认启用你的服务。在代码中,可以引导用户跳转到相应的设置页面进行授权。

其他典型应用场景

Android辅助服务主要用于以下场景:

  • 辅助工具: 屏幕阅读器、盲人导航应用、放大镜等。
  • 自动化和效率工具:
    • 自动跳过开屏广告。
    • 模拟用户操作完成重复的签到、点赞等任务。
    • 在特定条件下自动执行点击操作。
  • 家长控制与安全监控: 监控孩子使用的应用、限制特定操作。
  • 跨应用功能: 实现全局快捷操作,例如截屏或启动特定功能。

开发注意事项(重要!)

由于其高权限特性, GoogleAccessibilityService 的使用有严格的政策要求:

  1. 目的透明: 你的应用必须有一个清晰且可访问的核心功能,直接需要 AccessibilityService 的权限才能工作。例如,如果你的应用是一个自动化工具,这是合理的。
  2. 明确告知: 必须在应用内明显的位置(如 $\text{Google Play}$ 描述、首次使用提示)明确告知用户你的应用使用此服务的原因,以及它将访问哪些数据。
  3. 不得滥用: 严禁用于窃取用户隐私信息(如密码、银行卡信息)或在用户不知情的情况下进行欺诈性点击。

总结来说,AccessibilityService 是 Android 开发者实现跨应用自动化和高级交互功能的强大工具。

使用时,务必遵守法律法规,确保用户的知情权和数据安全。

【Android基础】ADB调试命令大全

【Android基础】ADB调试命令大全

本文记录了比较丰富而简单的日常使用过程中的adb调试命令

前言

这篇笔记用来收集在日常开发中所用到的安卓adb shell命令,参照了一些大佬的再加上我自己平时用到的整理在了一块儿,感谢无私共享的大佬们。 将会持续更新,欢迎收藏~

车载补充

采集tcp报文

基本语法:

tcpdump [ -AbdDefhHIJKlLnNOpqStuUvxX# ] [ -B buffer_size ] [ -c count ]
    [ -C file_size ] [ -E algo:secret ] [ -F file ] [ -G seconds ]
    [ -i interface ] [ -j tstamptype ] [ -M secret ] [ -P in|out|inout ]
    [ -r file ] [ -s snaplen ] [ -T type ] [ -w file ] [ -W filecount ]
    [ -y datalinktype ] [ -z command ] [ -Z user ] [ expression ]    

常用选项

  • -i:指定要监听的网络接口,如 eth0、wlan0 等。若不指定,tcpdump 会默认选择系统中的第一个可用网络接口。例如,tcpdump -i eth0表示在 eth0 接口上进行抓包。
  • -c:指定要捕获的数据包数量。当达到指定数量后,tcpdump 会自动停止抓包。例如,tcpdump -c 10表示只捕获 10 个数据包。
  • -s:设置每个数据包的抓取长度,默认一般为 68 字节或 96 字节,对于较大的数据包可能无法完整抓取。可以使用 - s 0 指定抓取完整的数据包。例如,tcpdump -s 1500表示抓取每个数据包的前 1500 字节。
  • -w:将捕获的数据包保存到指定的文件中,以便后续进行离线分析。例如,tcpdump -w packet.pcap会把捕获的数据包保存到 packet.pcap 文件中,该文件可以使用 Wireshark 等工具打开分析。
  • -r:从指定的文件中读取数据包并进行分析,而不是从网络接口实时捕获。例如,tcpdump -r packet.pcap可以查看之前保存的 packet.pcap 文件中的数据包。

过滤表达式

  • 按协议过滤:可以指定要捕获的特定协议的数据包,如 tcp、udp、icmp 等。例如,tcpdump tcp只捕获 TCP 协议的数据包,tcpdump udp or icmp则捕获 UDP 或 ICMP 协议的数据包。
  • 按主机过滤:可以指定源主机、目的主机或两者都指定。例如,tcpdump host 192.168.1.100捕获进出 192.168.1.100 主机的所有数据包,tcpdump src 192.168.1.100只捕获源地址为 192.168.1.100 的数据包,tcpdump dst 192.168.1.200只捕获目的地址为 192.168.1.200 的数据包。
  • 按端口过滤:可以指定源端口、目的端口或两者都指定。例如,tcpdump port 80捕获进出端口 80 的所有数据包,tcpdump src port 22只捕获源端口为 22 的数据包,tcpdump dst port 53只捕获目的端口为 53 的数据包。
  • 组合过滤:可以使用逻辑运算符 &&(与)、||(或)、!(非)将多个过滤条件组合起来。例如,tcpdump 'tcp port 80 && host 192.168.1.100'捕获来自或发往 192.168.1.100 主机且端口为 80 的 TCP 数据包。 示例
  • 捕获并打印所有经过 eth0 接口的数据包: tcpdump -i eth0*
  • 捕获 100 个经过 eth0 接口的 HTTP 数据包(端口 80): tcpdump -i eth0 -c 100 port 80*
  • 捕获进出主机 192.168.1.100 的所有 ICMP 数据包,并保存到文件 icmp.pcap 中: tcpdump -i eth0 -w icmp.pcap host 192.168.1.100 and icmp

tcpdump 命令功能强大,但需要一定的网络知识和经验才能更好地使用,在实际使用中可能需要根据具体需求结合不同的选项和表达式进行灵活运用。

替换系统apk

车机预制的应用一般都在/system/priv-app/ 目录下,用户安装的应用一般都在/data/app/ 目录下。 替换系统应用的步骤:

adb root
adb remount
adb push <apk_file> /system/priv-app/<app_name>/base.apk
adb shell chmod 777 /system/priv-app/<app_name>/base.apk
adb reboot

一、基本用法

命令语法

adb 命令的基本语法如下:

adb [-d|-e|-s <serialNumber>] <command>

如果只有一个设备/模拟器连接时,可以省略掉[-d|-e|-s <serialNumber>]这一部分,直接使用 adb <command>。 为命令指定目标设备。如果有多个设备/模拟器连接,则需要为命令指定目标设备。

-d
指定当前唯一通过 USB 连接的 Android 设备为命令目标

-e
指定当前唯一运行的模拟器为命令目标

-s <serialNumber>
指定相应 serialNumber 号的设备/模拟器为命令目标

在多个设备/模拟器连接的情况下较常用的是 -s <serialNumber> 参数,serialNumber 可以通过 adb devices 命令获取。如:

$ adb devices
List of devices attached
cf264b8f	device
emulator-5554	device
10.129.164.6:5555	device

输出里的 cf264b8f、emulator-5554 和 10.129.164.6:5555 即为 serialNumber。

比如这时想指定 cf264b8f 这个设备来运行 adb 命令获取屏幕分辨率:

adb -s cf264b8f shell wm size

又如想给 10.129.164.6:5555 这个设备安装应用(这种形式的 serialNumber 格式为<IP>:<Port>,一般为无线连接的设备或 Genymotion 等第三方 Android 模拟器):

adb -s 10.129.164.6:5555 install test.apk

遇到多设备/模拟器的情况均使用这几个参数为命令指定目标设备,下文中为简化描述,不再重复。

启动/停止

启动 adb server 命令:

adb start-server

(一般无需手动执行此命令,在运行 adb 命令时若发现 adb server 没有启动会自动调起。)

停止 adb server 命令:

adb kill-server

查看 adb 版本 命令:

adb version

示例输出:

Android Debug Bridge version 1.0.36
Revision 8f855a3d9b35-android

以 root 权限运行 adbd

adb 的运行原理是 PC 端的 adb server 与手机端的守护进程 adbd 建立连接,然后 PC 端的 adb client 通过 adb server 转发命令,adbd 接收命令后解析运行。

所以如果 adbd 以普通权限执行,有些需要 root 权限才能执行的命令无法直接用 adb xxx 执行。这时可以 adb shell 然后 su 后执行命令,也可以让 adbd 以 root 权限执行,这个就能随意执行高权限命令了。

命令:

adb root

正常输出:

restarting adbd as root

现在再运行 adb shell,看看命令行提示符是不是变成 # 了?

有些手机 root 后也无法通过 adb root 命令让 adbd 以 root 权限执行,比如三星的部分机型,会提示 adbd cannot run as root in production builds,此时可以先安装 adbd Insecure,然后 adb root 试试。

相应地,如果要恢复 adbd 为非 root 权限的话,可以使用

adb unroot

指定 adb server 的网络端口:

adb -P <port> start-server

默认端口为 5037。

二、设备连接管理

查询已连接设备/模拟器

adb devices

输出示例:

List of devices attached
cf264b8f	device
emulator-5554	device
10.129.164.6:5555	device

输出格式为[serialNumber] [state],serialNumber 即我们常说的 SN,state 有如下几种:

  • offline —— 表示设备未连接成功或无响应。
  • device —— 设备已连接。注意这个状态并不能标识 Android 系统已经完全启动和可操作,在设备启动过程中设备实例就可连接到 adb,但启动完毕后系统才处于可操作状态。
  • no device —— 没有设备/模拟器连接。

以上输出显示当前已经连接了三台设备/模拟器,cf264b8f、 emulator-5554 和 10.129.164.6:5555 分别是它们的 SN。从 emulator-5554 这个名字可以看出它是一个 Android 模拟器,而 10.129.164.6:5555 这种形为<IP>:<Port> 的 serialNumber 一般是无线连接的设备或 Genymotion 等第三方 Android 模拟器。

常见异常输出:

  1. 没有设备/模拟器连接成功。 List of devices attached
  2. 设备/模拟器未连接到 adb 或无响应。 List of devices attached cf264b8f offline

USB 连接

通过 USB 连接来正常使用 adb 需要保证几点:

  1. 硬件状态正常。 包括 Android 设备处于正常开机状态,USB 连接线和各种接口完好。
  2. Android 设备的开发者选项和 USB 调试模式已开启。 可以到「设置」-「开发者选项」-「Android 调试」查看。 如果在设置里找不到开发者选项,那需要通过一个彩蛋来让它显示出来:在「设置」-「关于手机」连续点击「版本号」7 次。
  3. 设备驱动状态正常。 这一点貌似在 Linux 和 Mac OS X 下不用操心,在 Windows 下有可能遇到需要安装驱动的情况,确认这一点可以右键「计算机」-「属性」,到「设备管理器」里查看相关设备上是否有黄色感叹号或问号,如果没有就说明驱动状态已经好了。否则可以下载一个手机助手类程序来安装驱动先。
  4. 通过 USB 线连接好电脑和设备后确认状态。adb devices,如果能看到 xxxxxx device,说明连接成功。

无线连接(Android11 及以上)

Android 11 及更高版本支持使用 Android 调试桥 (adb) 从工作站以无线方式部署和调试应用。例如,您可以将可调试应用部署到多台远程设备,而无需通过 USB 实际连接设备。这样就可以避免常见的 USB 连接问题,例如驱动程序安装方面的问题。

操作步骤:

  1. 更新到最新版本的 SDK 平台工具(至少30.0.0)。
  2. 将 Android 设备与要运行 adb 的电脑连接到同一个局域网,比如连到同一个 WiFi。
  3. 在开发者选项中启用无线调试。
  4. 在询问要允许在此网络上进行无线调试吗?的对话框中,点击允许。
  5. 选择使用配对码配对设备,使用弹窗中的 IP 地址和端口号。 adb pair ipaddr:port
  6. 提示Enter pairing code: 时输入弹窗中的配对码,成功后会显示Successfully paired to …。
  7. 使用无线调试下的 IP 地址和端口。 adb connect ipaddr:port
  8. 确认连接状态。adb devices,如果能看到,ipaddr:port device,说明连接成功。

无线连接(需要借助 USB 线)

除了可以通过 USB 连接设备与电脑来使用 adb,也可以通过无线连接——虽然连接过程中也有需要使用 USB 的步骤,但是连接成功之后你的设备就可以在一定范围内摆脱 USB 连接线的限制啦! 操作步骤:

  1. 将 Android 设备与要运行 adb 的电脑连接到同一个局域网,比如连到同一个 WiFi。
  2. 将设备与电脑通过 USB 线连接。 应确保连接成功(可运行 adb devices 看是否能列出该设备)。
  3. 让设备在 5555 端口监听 TCP/IP 连接: adb tcpip 5555
  4. 断开 USB 连接。
  5. 找到设备的 IP 地址。 一般能在「设置」-「关于手机」-「状态信息」-「IP地址」找到,也可以使用下文里[查看设备信息 - IP 地址][1] 一节里的方法用 adb 命令来查看。
  6. 通过 IP 地址连接设备。 adb connect <device-ip-address> 这里的<device-ip-address> 就是上一步中找到的设备 IP 地址。
  7. 确认连接状态。 adb devices 如果能看到 <device-ip-address>:5555 device 说明连接成功。

如果连接不了,请确认 Android 设备与电脑是连接到了同一个 WiFi,然后再次执行 adb connect <device-ip-address> 那一步; 如果还是不行的话,通过 adb kill-server 重新启动 adb 然后从头再来一次试试。

断开无线连接

adb disconnect <device-ip-address>

三、应用管理

查看应用列表

查看应用列表的基本命令格式是:

adb shell pm list packages [-f] [-d] [-e] [-s] [-3] [-i] [-u] [--user USER_ID] [FILTER]

即在 adb shell pm list packages 的基础上可以加一些参数进行过滤查看不同的列表,支持的过滤参数如下: |参数|显示列表| |:—|:—-| |无|所有应用| |-f|所有应用及 apk 文件路径| |-d|所有禁用的应用| |-e|所有启用的应用| |-s|所有系统应用| |-3|所有第三方应用| |-i|所有已安装的应用| |-u|包含已卸载应用| |<FILTER>|包名包含 <FILTER> 字符串|

所有应用

命令:

adb shell pm list packages

输出示例:

package:com.android.smoketest
package:com.example.android.livecubes
package:com.android.providers.telephony
package:com.google.android.googlequicksearchbox
package:com.android.providers.calendar
package:com.android.providers.media
package:com.android.protips
package:com.android.documentsui
package:com.android.gallery
package:com.android.externalstorage
...
// other packages here
...

系统应用

命令:

adb shell pm list packages -s

第三方应用

命令:

adb shell pm list packages -3

包名包含某字符串的应用

比如要查看包名包含字符串 mazhuang 的应用列表,命令:

adb shell pm list packages mazhuang

当然也可以使用 grep 来过滤:

adb shell pm list packages | grep mazhuang

安装 APK

命令格式:

adb install [-lrtsdg] <path_to_apk>

参数:

adb install 后面可以跟一些可选参数来控制安装 APK 的行为,可用参数及含义如下: |参数|含义| |:—|:—-| |-l|将应用安装到保护目录 /mnt/asec| |-r|允许覆盖安装| |-t|允许安装 AndroidManifest.xml 里 application 指定 android:testOnly=”true” 的应用| |-s|将应用安装到 sdcard| |-d|允许降级覆盖安装| |-g|授予所有运行时权限| |–abi abi-identifier|为特定 ABI 强制安装 apk,abi-identifier 可以是 armeabi-v7a、arm64-v8a、v86、x86_64 等|

运行命令后如果见到类似如下输出(状态为 Success)代表安装成功:

[100%] /data/local/tmp/1.apk
	pkg: /data/local/tmp/1.apk
Success

上面是当前最新版 v1.0.36 的 adb 的输出,会显示 push apk 文件到手机的进度百分比。

使用旧版本 adb 的输出则是这样的:

12040 KB/s (22205609 bytes in 1.801s)
        pkg: /data/local/tmp/SogouInput_android_v8.3_sweb.apk
Success

而如果状态为 Failure 则表示安装失败,比如:

[100%] /data/local/tmp/map-20160831.apk
        pkg: /data/local/tmp/map-20160831.apk
Failure [INSTALL_FAILED_ALREADY_EXISTS]

常见安装失败输出代码、含义可以百度对应。

安装流程

参考:PackageManager.java adb install 内部原理简介 adb install 实际是分三步完成:

  1. push apk 文件到 /data/local/tmp。
  2. 调用 pm install 安装。
  3. 删除 /data/local/tmp 下的对应 apk 文件。

所以,必要的时候也可以根据这个步骤,手动分步执行安装过程。

卸载应用

adb uninstall [-k] <packagename>

<packagename> 表示应用的包名,-k 参数可选,表示卸载应用但保留数据和缓存目录。 命令示例:

adb uninstall com.qihoo360.mobilesafe

表示卸载 360 手机卫士。

清除应用数据与缓存

adb shell pm clear <packagename>

<packagename> 表示应用名包,这条命令的效果相当于在设置里的应用信息界面点击了「清除缓存」和「清除数据」。

命令示例:

adb shell pm clear com.qihoo360.mobilesafe

表示清除 360 手机卫士的数据和缓存。

查看前台 Activity

adb shell dumpsys activity activities | grep mResumedActivity

输出示例:

mResumedActivity: ActivityRecord{8079d7e u0 com.cyanogenmod.trebuchet/com.android.launcher3.Launcher t42}

其中的 com.cyanogenmod.trebuchet/com.android.launcher3.Launcher 就是当前处于前台的 Activity。

在 Windows 下以上命令可能不可用,可以尝试adb shell dumpsys activity activities | findstr mResumedActivityadb shell "dumpsys activity activities | grep mResumedActivity"

查看正在运行的 Services

adb shell dumpsys activity services [<packagename>]

<packagename> 参数不是必须的,指定<packagename> 表示查看与某个包名相关的 Services,不指定表示查看所有 Services。 <packagename> 不一定要给出完整的包名,比如运行 adb shell dumpsys activity services org.mazhuang,那么包名 org.mazhuang.demo1、org.mazhuang.demo2 和 org.mazhuang123 等相关的 Services 都会列出来。

查看应用详细信息

adb shell dumpsys package <packagename>

输出中包含很多信息,包括 Activity Resolver Table、Registered ContentProviders、包名、userId、安装后的文件资源代码等路径、版本信息、权限信息和授予状态、签名版本信息等。 <packagename> 表示应用包名。

输出示例:

Activity Resolver Table:
  Non-Data Actions:
      android.intent.action.MAIN:
        5b4cba8 org.mazhuang.guanggoo/.SplashActivity filter 5ec9dcc
          Action: "android.intent.action.MAIN"          Category: "android.intent.category.LAUNCHER"          AutoVerify=falseRegistered ContentProviders:
  org.mazhuang.guanggoo/com.tencent.bugly.beta.utils.BuglyFileProvider:
    Provider{7a3c394 org.mazhuang.guanggoo/com.tencent.bugly.beta.utils.BuglyFileProvider}
ContentProvider Authorities:
  [org.mazhuang.guanggoo.fileProvider]:
    Provider{7a3c394 org.mazhuang.guanggoo/com.tencent.bugly.beta.utils.BuglyFileProvider}
      applicationInfo=ApplicationInfo{7754242 org.mazhuang.guanggoo}
Key Set Manager:
  [org.mazhuang.guanggoo]
      Signing KeySets: 501
Packages:
  Package [org.mazhuang.guanggoo] (c1d7f):
    userId=10394
    pkg=Package{55f714c org.mazhuang.guanggoo}
    codePath=/data/app/org.mazhuang.guanggoo-2
    resourcePath=/data/app/org.mazhuang.guanggoo-2
    legacyNativeLibraryDir=/data/app/org.mazhuang.guanggoo-2/lib
    primaryCpuAbi=null
    secondaryCpuAbi=null
    versionCode=74 minSdk=15 targetSdk=25
    versionName=1.1.74
    splits=[base]
    apkSigningVersion=2
    applicationInfo=ApplicationInfo{7754242 org.mazhuang.guanggoo}
    flags=[ HAS_CODE ALLOW_CLEAR_USER_DATA ALLOW_BACKUP ]
    privateFlags=[ RESIZEABLE_ACTIVITIES ]
    dataDir=/data/user/0/org.mazhuang.guanggoo
    supportsScreens=[small, medium, large, xlarge, resizeable, anyDensity]
    timeStamp=2017-10-22 23:50:53
    firstInstallTime=2017-10-22 23:50:25
    lastUpdateTime=2017-10-22 23:50:55
    installerPackageName=com.miui.packageinstaller
    signatures=PackageSignatures{af09595 [53c7caa2]}
    installPermissionsFixed=true installStatus=1
    pkgFlags=[ HAS_CODE ALLOW_CLEAR_USER_DATA ALLOW_BACKUP ]
    requested permissions:
      android.permission.READ_PHONE_STATE
      android.permission.INTERNET
      android.permission.ACCESS_NETWORK_STATE
      android.permission.ACCESS_WIFI_STATE
      android.permission.READ_LOGS
      android.permission.WRITE_EXTERNAL_STORAGE
      android.permission.READ_EXTERNAL_STORAGE
    install permissions:
      android.permission.INTERNET: granted=true      android.permission.ACCESS_NETWORK_STATE: granted=true      android.permission.ACCESS_WIFI_STATE: granted=true    User 0: ceDataInode=1155675 installed=true hidden=false suspended=false stopped=true notLaunched=false enabled=0
      gids=[3003]
      runtime permissions:
        android.permission.READ_EXTERNAL_STORAGE: granted=true        android.permission.READ_PHONE_STATE: granted=true        android.permission.WRITE_EXTERNAL_STORAGE: granted=true    User 999: ceDataInode=0 installed=false hidden=false suspended=false stopped=true notLaunched=true enabled=0
      gids=[3003]
      runtime permissions:
Dexopt state:
  [org.mazhuang.guanggoo]
    Instruction Set: arm64
      path: /data/app/org.mazhuang.guanggoo-2/base.apk
      status: /data/app/org.mazhuang.guanggoo-2/oat/arm64/base.odex [compilation_filter=speed-profile, status=kOatUpToDa
      te]

查看应用安装路径

adb shell pm path <PACKAGE>

输出应用安装路径

adb shell pm path ecarx.weather

输出:

package:/data/app/ecarx.weather-1.apk

四、与应用交互

通常情况下adb与应用交互需要知道包名和Activity名,这里介绍了个在win系统时获取这些名的方法 命令:

aapt dump badging 包名(apk)| findstr package

这里实际是用了两个命令:aapt和findstr,aapt获取APK信息,findstr查找包含package的行(类似于linux的grep),结果如下:

package: name='com.naeiq1est.navi' versionCode='2023013001' versionName='2023.01.30.01'

以上为获取到了包名,如需获取到Activity名只需要修改findstr的字串为activity,结果如下:

launchable activity name='com.naviquest.navi.ScreenConfig'label='??????' icon=''

命令介绍 主要是使用am <command> 命令,常用的 <command> 如下:

start [options] <INTENT>
启动 <INTENT> 指定的 Activity

startservice [options] <INTENT>
启动 <INTENT> 指定的 Service

broadcast [options] <INTENT>
发送 <INTENT> 指定的广播

force-stop <packagename>
停止 <packagename> 相关的进程

<INTENT> 参数很灵活,和写 Android 程序时代码里的 Intent 相对应。
用于决定 intent 对象的选项如下:
参数
含义

-a <ACTION>
指定 action,比如 android.intent.action.VIEW

-c <CATEGORY>
指定 category,比如 android.intent.category.APP_CONTACTS

-n <COMPONENT>
指定完整 component 名,用于明确指定启动哪个 Activity,如 com.example.app/.ExampleActivity

<INTENT> 里还能带数据,就像写代码时的 Bundle 一样:
参数
含义

--esn <EXTRA_KEY>
null 值(只有 key 名)

`-e
--es <EXTRA_KEY> <EXTRA_STRING_VALUE>`

--ez <EXTRA_KEY> <EXTRA_BOOLEAN_VALUE>
boolean 值

--ei <EXTRA_KEY> <EXTRA_INT_VALUE>
integer 值

--el <EXTRA_KEY> <EXTRA_LONG_VALUE>
long 值

--ef <EXTRA_KEY> <EXTRA_FLOAT_VALUE>
float 值

--eu <EXTRA_KEY> <EXTRA_URI_VALUE>
URI

--ecn <EXTRA_KEY> <EXTRA_COMPONENT_NAME_VALUE>
component name

--eia <EXTRA_KEY> <EXTRA_INT_VALUE>[,<EXTRA_INT_VALUE...]
integer 数组

--ela <EXTRA_KEY> <EXTRA_LONG_VALUE>[,<EXTRA_LONG_VALUE...]
long 数组

启动应用/ 调起 Activity

指定Activity名称启动

命令格式:
adb shell am start [options] <INTENT>
例如:
adb shell am start -n com.tencent.mm/.ui.LauncherUI
表示调起微信主界面。
adb shell am start -n org.mazhuang.boottimemeasure/.MainActivity --es "toast""hello, world"

表示调起 org.mazhuang.boottimemeasure/.MainActivity 并传给它 string 数据键值对 toast - hello, world。

不指定Activity名称启动(启动主Activity)

命令格式:

adb shell monkey -p <packagename> -c android.intent.category.LAUNCHER 1

例如:

adb shell monkey -p com.tencent.mm -c android.intent.category.LAUNCHER 1

表示调起微信主界面。

调起 Service

命令格式:

adb shell am startservice [options] <INTENT>

例如:

adb shell am startservice -n com.tencent.mm/.plugin.accountsync.model.AccountAuthenticatorService

表示调起微信的某 Service。

另外一个典型的用例是如果设备上原本应该显示虚拟按键但是没有显示,可 以试试这个:

adb shell am startservice -n com.android.systemui/.SystemUIService

停止 Service

命令格式:

adb shell am stopservice [options] <INTENT>

发送广播

命令格式:

adb shell am broadcast [options] <INTENT>

可以向所有组件广播,也可以只向指定组件广播。

例如,向所有组件广播 BOOT_COMPLETED:

adb shell am broadcast -a android.intent.action.BOOT_COMPLETED

又例如,只向 org.mazhuang.boottimemeasure/.BootCompletedReceiver 广播 BOOT_COMPLETED:

adb shell am broadcast -a android.intent.action.BOOT_COMPLETED -n org.mazhuang.boottimemeasure/.BootCompletedReceiver

这类用法在测试的时候很实用,比如某个广播的场景很难制造,可以考虑通过这种方式来发送广播。

既能发送系统预定义的广播,也能发送自定义广播。如下是部分系统预定义广播及正常触发时机:

android.net.conn.CONNECTIVITY_CHANGE
网络连接发生变化

android.intent.action.SCREEN_ON
屏幕点亮

android.intent.action.SCREEN_OFF
屏幕熄灭

android.intent.action.BATTERY_LOW
电量低,会弹出电量低提示框

android.intent.action.BATTERY_OKAY
电量恢复了

android.intent.action.BOOT_COMPLETED
设备启动完毕

android.intent.action.DEVICE_STORAGE_LOW
存储空间过低

android.intent.action.DEVICE_STORAGE_OK
存储空间恢复

android.intent.action.PACKAGE_ADDED
安装了新的应用

android.net.wifi.STATE_CHANGE
WiFi 连接状态发生变化

android.net.wifi.WIFI_STATE_CHANGED
WiFi 状态变为启用/关闭/正在启动/正在关闭/未知

android.intent.action.BATTERY_CHANGED
电池电量发生变化

android.intent.action.INPUT_METHOD_CHANGED
系统输入法发生变化

android.intent.action.ACTION_POWER_CONNECTED
外部电源连接

android.intent.action.ACTION_POWER_DISCONNECTED
外部电源断开连接

android.intent.action.DREAMING_STARTED
系统开始休眠

android.intent.action.DREAMING_STOPPED
系统停止休眠

android.intent.action.WALLPAPER_CHANGED
壁纸发生变化

android.intent.action.HEADSET_PLUG
插入耳机

android.intent.action.MEDIA_UNMOUNTED
卸载外部介质

android.intent.action.MEDIA_MOUNTED
挂载外部介质

android.os.action.POWER_SAVE_MODE_CHANGED
省电模式开启

(以上广播均可使用 adb 触发)

强制停止应用

命令:

adb shell am force-stop <packagename>

命令示例:

adb shell am force-stop com.qihoo360.mobilesafe

表示停止 360 安全卫士的一切进程与服务。

收紧内存

命令:

adb shell am send-trim-memory  <pid> <level>

pid: 进程 ID level:HIDDEN、RUNNING_MODERATE、BACKGROUND、 RUNNING_LOW、MODERATE、RUNNING_CRITICAL、COMPLETE

命令示例:

adb shell am send-trim-memory 12345 RUNNING_LOW

表示向 pid=12345 的进程,发出 level=RUNNING_LOW 的收紧内存命令。

五、文件管理

复制设备里的文件到电脑

命令:

adb pull <设备里的文件路径> [电脑上的目录]

其中 电脑上的目录 参数可以省略,默认复制到当前目录。 例:

adb pull /sdcard/sr.mp4 ~/tmp/

小技巧:设备上的文件路径可能需要 root 权限才能访问,如果你的设备已经 root 过,可以先使用 adb shell 和 su 命令在 adb shell 里获取 root 权限后,先 cp /path/on/device /sdcard/filename 将文件复制到 sdcard,然后 adb pull /sdcard/filename /path/on/pc。

复制电脑里的文件到设备

命令:

adb push <电脑上的文件路径> <设备里的目录>

例:

adb push ~/sr.mp4 /sdcard/

小技巧:设备上的文件路径普通权限可能无法直接写入,如果你的设备已经 root 过,可以先 adb push /path/on/pc /sdcard/filename,然后 adb shell 和 su 在 adb shell 里获取 root 权限后,cp /sdcard/filename /path/on/device。

六、模拟按键/输入

在 adb shell 里有个很实用的命令叫 input,通过它可以做一些有趣的事情。 input 命令的完整 help 信息如下:

Usage: input [<source>] <command> [<arg>...]
The sources are:
      mouse
      keyboard
      joystick
      touchnavigation
      touchpad
      trackball
      stylus
      dpad
      gesture
      touchscreen
      gamepad
The commands and default sources are:
      text <string> (Default: touchscreen)
      keyevent [--longpress] <key code number or name> ... (Default: keyboard)
      tap <x> <y> (Default: touchscreen)
      swipe <x1> <y1> <x2> <y2> [duration(ms)] (Default: touchscreen)
      press (Default: trackball)
      roll <dx> <dy> (Default: trackball)

比如使用adb shell input keyevent <keycode> 命令,不同的 keycode 能实现不同的功能,完整的 keycode 列表详见 KeyEvent,摘引部分我觉得有意思的如下:

3
HOME 键

4
返回键

5
打开拨号应用

6
挂断电话

24
增加音量

25
降低音量

26
电源键

27
拍照(需要在相机应用里)

64
打开浏览器

82
菜单键

85
播放/暂停

86
停止播放

87
播放下一首

88
播放上一首

122
移动光标到行首或列表顶部

123
移动光标到行末或列表底部

126
恢复播放

127
暂停播放

164
静音

176
打开系统设置

187
切换应用

207
打开联系人

208
打开日历

209
打开音乐

210
打开计算器

220
降低屏幕亮度

221
提高屏幕亮度

223
系统休眠

224
点亮屏幕

231
打开语音助手

276
如果没有 wakelock 则让系统休眠

下面是 input 命令的一些用法举例。

电源键
命令:
adb shell input keyevent 26
执行效果相当于按电源键。
菜单键
命令:
adb shell input keyevent 82
HOME 键
命令:
adb shell input keyevent 3
返回键
命令:
adb shell input keyevent 4
音量控制
增加音量:
adb shell input keyevent 24
降低音量:
adb shell input keyevent 25
静音:
adb shell input keyevent 164
媒体控制
播放/暂停:
adb shell input keyevent 85
停止播放:
adb shell input keyevent 86
播放下一首:
adb shell input keyevent 87
播放上一首:
adb shell input keyevent 88
恢复播放:
adb shell input keyevent 126
暂停播放:
adb shell input keyevent 127
点亮/熄灭屏幕
可以通过上文讲述过的模拟电源键来切换点亮和熄灭屏幕,但如果明确地想要点亮或者熄灭屏幕,那可以使用如下方法。
点亮屏幕:
adb shell input keyevent 224
熄灭屏幕:
adb shell input keyevent 223
滑动解锁
如果锁屏没有密码,是通过滑动手势解锁,那么可以通过 input swipe 来解锁。
命令(参数以机型 Nexus 5,向上滑动手势解锁举例):
adb shell input swipe 300 1000 300 500
参数 300 1000 300 500 分别表示起始点x坐标 起始点y坐标 结束点x坐标 结束点y坐标。
输入文本
在焦点处于某文本框时,可以通过 input 命令来输入文本。
命令:
adb shell input text hello
现在 hello 出现在文本框了。

七、查看日志

Android 系统的日志分为两部分,底层的 Linux 内核日志输出到 /proc/kmsg,Android 的日志输出到 /dev/log。

Android 日志

命令格式:

[adb] logcat [<option>] ... [<filter-spec>] ...

常用用法列举如下:

按级别过滤日志

Android 的日志分为如下几个优先级(priority):

  • V —— Verbose(最低,输出得最多)
  • D —— Debug
  • I —— Info
  • W —— Warning
  • E —— Error
  • F —— Fatal
  • S —— Silent(最高,啥也不输出)

按某级别过滤日志则会将该级别及以上的日志输出。 比如,命令:

adb logcat *:W

会将 Warning、Error、Fatal 和 Silent 日志输出。

(注: 在 macOS 下需要给 :W 这样以 * 作为 tag 的参数加双引号,如 adb logcat “:W”,不然会报错 no matches found: *:W。)

按 tag 和级别过滤日志

<filter-spec> 可以由多个<tag>[:priority]组成。

比如,命令:

adb logcat ActivityManager:I MyApp:D *:S

表示输出 tag ActivityManager 的 Info 以上级别日志,输出 tag MyApp 的 Debug 以上级别日志,及其它 tag 的 Silent 级别日志(即屏蔽其它 tag 日志)。

日志格式

可以用 adb logcat -v <format> 选项指定日志输出格式。 日志支持按以下几种<format>

* brief
默认格式。格式为:
<priority>/<tag>(<pid>): <message>
示例:
D/HeadsetStateMachine( 1785): Disconnected process message: 10, size: 0
* process
格式为:
<priority>(<pid>) <message>
示例:
D( 1785) Disconnected process message: 10, size: 0  (HeadsetStateMachine)
* tag
格式为:
<priority>/<tag>: <message>
示例:
D/HeadsetStateMachine: Disconnected process message: 10, size: 0
* raw
格式为:
<message>
示例:
Disconnected process message: 10, size: 0
* time
格式为:
<datetime> <priority>/<tag>(<pid>): <message>
示例:
08-28 22:39:39.974 D/HeadsetStateMachine( 1785): Disconnected process message: 10, size: 0
* threadtime
格式为:
<datetime> <pid> <tid> <priority> <tag>: <message>
示例:
08-28 22:39:39.974  1785  1832 D HeadsetStateMachine: Disconnected process message: 10, size: 0
* long
格式为:
[ <datetime> <pid>:<tid> <priority>/<tag> ]
<message>
示例:
[ 08-28 22:39:39.974  1785: 1832 D/HeadsetStateMachine ]
Disconnected process message: 10, size: 0

指定格式可与上面的过滤同时使用。比如:

adb logcat -v long ActivityManager:I *:S

清空日志

adb logcat -c

内核日志

命令:

adb shell dmesg

输出示例:

<6>[14201.684016] PM: noirq resume of devices complete after 0.982 msecs
<6>[14201.685525] PM: early resume of devices complete after 0.838 msecs
<6>[14201.753642] PM: resume of devices complete after 68.106 msecs
<4>[14201.755954] Restarting tasks ... done.
<6>[14201.771229] PM: suspendexit 2016-08-28 13:31:32.679217193 UTC
<6>[14201.872373] PM: suspend entry 2016-08-28 13:31:32.780363596 UTC
<6>[14201.872498] PM: Syncing filesystems ... done.

中括号里的 [14201.684016]代表内核开始启动后的时间,单位为秒。

通过内核日志我们可以做一些事情,比如衡量内核启动时间,在系统启动完毕后的内核日志里找到 Freeing init memory 那一行前面的时间就是。

八、设备信息

型号 命令:

adb shell getprop ro.product.model

输出示例:

Nexus 5

电池状况 命令:

adb shell dumpsys battery

输入示例:

Current Battery Service state:
  AC powered: false  USB powered: true  Wireless powered: false  status: 2
  health: 2
  present: true  level: 44
  scale: 100
  voltage: 3872
  temperature: 280
  technology: Li-poly

其中 scale 代表最大电量,level 代表当前电量。上面的输出表示还剩下 44% 的电量。

屏幕分辨率

命令:

adb shell wm size

输出示例:

Physical size: 1080x1920

该设备屏幕分辨率为 1080px * 1920px。 如果使用命令修改过,那输出可能是:

Physical size: 1080x1920
Override size: 480x1024

表明设备的屏幕分辨率原本是 1080px * 1920px,当前被修改为 480px * 1024px。

屏幕密度

命令:

adb shell wm density

输出示例:

Physical density: 420

该设备屏幕密度为 420dpi。 如果使用命令修改过,那输出可能是:

Physical density: 480
Override density: 160

表明设备的屏幕密度原来是 480dpi,当前被修改为 160dpi。

显示屏参数

命令:

adb shell dumpsys window displays

输出示例:

WINDOW MANAGER DISPLAY CONTENTS (dumpsys window displays)
  Display: mDisplayId=0
    init=1080x1920 420dpi cur=1080x1920 app=1080x1794 rng=1080x1017-1810x1731
    deferred=false layoutNeeded=false

其中 mDisplayId 为 显示屏编号,init 是初始分辨率和屏幕密度,app 的高度比 init 里的要小,表示屏幕底部有虚拟按键,高度为 1920 - 1794 = 126px 合 42dp。

android_id

查看命令:

adb shell settings get secure android_id

或:

adb shell content query --uri content://settings/secure --where "name=\'android_id\'"

输出示例:

51b6be48bac8c569

设置命令

adb shell settings put secure android_id 123456789addvff

IMEI 在 Android 4.4 及以下版本可通过如下命令获取 IMEI:

adb shell dumpsys iphonesubinfo

输出示例:

Phone Subscriber Info:
  Phone Type = GSM
  Device ID = 860955027785041

其中的 Device ID 就是 IMEI。 而在 Android 5.0 及以上版本里这个命令输出为空,得通过其它方式获取了(需要 root 权限):

adb shell
su
service call iphonesubinfo 1
输出示例:
Result: Parcel(
  0x00000000: 00000000 0000000f 00360038 00390030 '........8.6.0.9.'  0x00000010: 00350035 00320030 00370037 00350038 '5.5.0.2.7.7.8.5.'  0x00000020: 00340030 00000031                   '0.4.1...        ')

把里面的有效内容提取出来就是 IMEI 了,比如这里的是

860955027785041。

参考:

adb shell dumpsys iphonesubinfo not working since Android 5.0 Lollipop

Android 系统版本

命令:
adb shell getprop ro.build.version.release
输出示例:
5.0.2

IP 地址

每次想知道设备的 IP 地址的时候都得「设置」-「关于手机」-「状态信息」-「IP地址」很烦对不对?通过 adb 可以方便地查看。 命令:

adb shell ifconfig | grep Mask

输出示例:

inet addr:10.130.245.230  Mask:255.255.255.252
inet addr:127.0.0.1  Mask:255.0.0.0

那么 10.130.245.230 就是设备 IP 地址。 在有的设备上这个命令没有输出,如果设备连着 WiFi,可以使用如下命令来查看局域网 IP:

adb shell ifconfig wlan0

输出示例:

wlan0: ip 10.129.160.99 mask 255.255.240.0 flags [up broadcast running multicast]

wlan0     Link encap:UNSPEC
          inet addr:10.129.168.57  Bcast:10.129.175.255  Mask:255.255.240.0
          inet6 addr: fe80::66cc:2eff:fe68:b6b6/64 Scope: Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:496520 errors:0 dropped:0 overruns:0 frame:0
          TX packets:68215 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:3000
          RX bytes:116266821 TX bytes:8311736

如果以上命令仍然不能得到期望的信息,那可以试试以下命令(部分系统版本里可用):

adb shell netcfg

输出示例:

wlan0    UP                               10.129.160.99/20  0x00001043 f8:a9:d0:17:42:4d
lo       UP                                   127.0.0.1/8   0x00000049 00:00:00:00:00:00
p2p0     UP                                     0.0.0.0/0   0x00001003 fa:a9:d0:17:42:4d
sit0     DOWN                                   0.0.0.0/0   0x00000080 00:00:00:00:00:00
rmnet0   DOWN                                   0.0.0.0/0   0x00000000 00:00:00:00:00:00
rmnet1   DOWN                                   0.0.0.0/0   0x00000000 00:00:00:00:00:00
rmnet3   DOWN                                   0.0.0.0/0   0x00000000 00:00:00:00:00:00
rmnet2   DOWN                                   0.0.0.0/0   0x00000000 00:00:00:00:00:00
rmnet4   DOWN                                   0.0.0.0/0   0x00000000 00:00:00:00:00:00
rmnet6   DOWN                                   0.0.0.0/0   0x00000000 00:00:00:00:00:00
rmnet5   DOWN                                   0.0.0.0/0   0x00000000 00:00:00:00:00:00
rmnet7   DOWN                                   0.0.0.0/0   0x00000000 00:00:00:00:00:00
rev_rmnet3 DOWN                                   0.0.0.0/0   0x00001002 4e:b7:e4:2e:17:58
rev_rmnet2 DOWN                                   0.0.0.0/0   0x00001002 4e:f0:c8:bf:7a:cf
rev_rmnet4 DOWN                                   0.0.0.0/0   0x00001002 a6:c0:3b:6b:c4:1f
rev_rmnet6 DOWN                                   0.0.0.0/0   0x00001002 66:bb:5d:64:2e:e9
rev_rmnet5 DOWN                                   0.0.0.0/0   0x00001002 0e:1b:eb:b9:23:a0
rev_rmnet7 DOWN                                   0.0.0.0/0   0x00001002 7a:d9:f6:81:40:5a
rev_rmnet8 DOWN                                   0.0.0.0/0   0x00001002 4e:e2:a9:bb:d0:1b
rev_rmnet0 DOWN                                   0.0.0.0/0   0x00001002 fe:65:d0:ca:82:a9
rev_rmnet1 DOWN                                   0.0.0.0/0   0x00001002 da:d8:e8:4f:2e:fe

可以看到网络连接名称、启用状态、IP 地址和 Mac 地址等信息。

Mac 地址

命令:

adb shell cat /sys/class/net/wlan0/address

输出示例:

f8:a9:d0:17:42:4d

这查看的是局域网 Mac 地址,移动网络或其它连接的信息可以通过前面的小节「IP 地址」里提到的 adb shell netcfg 命令来查看。

CPU 信息

命令:

adb shell cat /proc/cpuinfo

输出示例:

Processor       : ARMv7 Processor rev 0 (v7l)
processor       : 0
BogoMIPS        : 38.40
processor       : 1
BogoMIPS        : 38.40
processor       : 2
BogoMIPS        : 38.40
processor       : 3
BogoMIPS        : 38.40
Features        : swp half thumb fastmult vfp edsp neon vfpv3 tls vfpv4 idiva idivt
CPU implementer : 0x51
CPU architecture: 7
CPU variant     : 0x2
CPU part        : 0x06f
CPU revision    : 0
Hardware        : Qualcomm MSM 8974 HAMMERHEAD (Flattened Device Tree)
Revision        : 000b
Serial          : 0000000000000000

这是 Nexus 5 的 CPU 信息,我们从输出里可以看到使用的硬件是 Qualcomm MSM 8974,processor 的编号是 0 到 3,所以它是四核的,采用的架构是 ARMv7 Processor rev 0 (v71)。

内存信息

命令:

adb shell cat /proc/meminfo

输出示例:

MemTotal:        1027424 kB
MemFree:          486564 kB
Buffers:           15224 kB
Cached:            72464 kB
SwapCached:        24152 kB
Active:           110572 kB
Inactive:         259060 kB
Active(anon):      79176 kB
Inactive(anon):   207736 kB
Active(file):      31396 kB
Inactive(file):    51324 kB
Unevictable:        3948 kB
Mlocked:               0 kB
HighTotal:        409600 kB
HighFree:         132612 kB
LowTotal:         617824 kB
LowFree:          353952 kB
SwapTotal:        262140 kB
SwapFree:         207572 kB
Dirty:                 0 kB
Writeback:             0 kB
AnonPages:        265324 kB
Mapped:            47072 kB
Shmem:              1020 kB
Slab:              57372 kB
SReclaimable:       7692 kB
SUnreclaim:        49680 kB
KernelStack:        4512 kB
PageTables:         5912 kB
NFS_Unstable:          0 kB
Bounce:                0 kB
WritebackTmp:          0 kB
CommitLimit:      775852 kB
Committed_AS:   13520632 kB
VmallocTotal:     385024 kB
VmallocUsed:       61004 kB
VmallocChunk:     209668 kB

其中,MemTotal 就是设备的总内存,MemFree 是当前空闲内存。

硬件与系统属性

设备的更多硬件与系统属性可以通过如下命令查看:

adb shell cat /system/build.prop

这会输出很多信息,包括前面几个小节提到的「型号」和「Android 系统版本」等。

输出里还包括一些其它有用的信息,它们也可通过 adb shell getprop <属性名> 命令单独查看,列举一部分属性如下:

ro.build.version.sdk
SDK 版本

ro.build.version.release
Android 系统版本

ro.build.version.security_patch
Android 安全补丁程序级别

ro.product.model
型号

ro.product.brand
品牌

ro.product.name
设备名

ro.product.board
处理器型号

ro.product.cpu.abilist
CPU 支持的 abi 列表[节注一]

persist.sys.isUsbOtgEnabled
是否支持 OTG

dalvik.vm.heapsize
每个应用程序的内存上限

ro.sf.lcd_density
屏幕密度

ro.build.id=GRI40
版本ID

ro.build.display.id=GRJ22
版本号

ro.build.version.incremental=eng.buildbot.20110619.060228
版本增量

ro.build.version.sdk=10
sdk版本

ro.build.version.codename=REL
版本代号

ro.build.version.release=2.3.4
Android 2.3.4系統无需修改,也可改为3.0装装B

ro.build.date=Sun Jun 19 06:02:58 UTC 2011
制作者制作的时间,可修改2011年X月X日 某某某制作

ro.build.date.utc=0


ro.build.type=user


ro.build.user=buildbot


ro.build.host=bb1


ro.build.tags=test-keys


ro.product.model=HTC Wildfire
HTC内部手机代号也就是手机名,改为你想改的名字

ro.product.brand=htc_wwe
手机品牌,改为中国山寨机

ro.product.name=htc_buzz
手机正式名称,改为你想改的名字

ro.product.device=buzz
采用的设备,改为China G8

ro.product.board=buzz
采用的处理器,改为China 800.8Ghz

ro.product.cpu.abi=armeabi-v6j
cpu的版本

ro.product.cpu.abi2=armeabi
cpu的品牌

ro.product.manufacturer=HTC
手机制造商,改为中国智造

ro.product.locale.language=en
手机默认语言,把en改为zh

ro.product.locale.region=US
地区语言,美国毛多呀美国,干掉US改为CN

ro.wifi.channels=
WIFI连接的渠道

ro.board.platform=msm7k
主板平台

ro.build.product=buzz
建立产品

ro.build.description=passion-user 2.3.3 GRI40 102588 release-keys
用户的KEY

ro.build.fingerprint=google/passion/passion:2.3.3/GRI40/102588:user/release-keys
机身码的啥玩意

节注一: 一些小厂定制的 ROM 可能修改过 CPU 支持的 abi 列表的属性名,如果用 ro.product.cpu.abilist 属性名查找不到,可以这样试试:

adb shell cat /system/build.prop | grep ro.product.cpu.abi

示例输出:

ro.product.cpu.abi=armeabi-v7a
ro.product.cpu.abi2=armeabi

查看/修改序列号

查看命令:

adb get-serialno

或:

adb shell getprop sys.serialno

修改序列号: 通常安卓系统正常流程需要修改 cmdline中的参数,可以通过命令查看cmdline参数:

 adb shell cat /proc/cmdline

显示:

storagemedia=emmc androidboot.mode=emmc androidboot.dtbo_idx=0 androidboot.slot_suffix= androidboot.serialno=9501a97c59fe092 console=ttyFIQ0 androidboot.baseband=N/A androidboot.wificountrycode=US androidboot.veritymode=enforcing androidboot.hardware=rk30board androidboot.console=ttyFIQ0 firmware_class.path=/vendor/etc/firmware init=/init rootwait ro init=/init root=PARTUUID=af01642c-9b84-11e8-9b2a-234eb5e198a0 loop.max_part=7 androidboot.selinux=permissive buildvariant=userdebug earlyprintk=uart8250,mmio32,0xff690000 swiotlb=1 kpti=0 bt_type=4

九、修改设置

注: 修改设置之后,运行恢复命令有可能显示仍然不太正常,可以运行 adb reboot 重启设备,或手动重启。 修改设置的原理主要是通过 settings 命令修改 /data/data/com.android.providers.settings/databases/settings.db 里存放的设置值。

分辨率

命令: adb shell wm size 480x1024 表示将分辨率修改为 480px * 1024px。 恢复原分辨率命令: adb shell wm size reset

屏幕密度

命令: adb shell wm density 160 表示将屏幕密度修改为 160dpi。 恢复原屏幕密度命令: adb shell wm density reset

显示区域

命令: adb shell wm overscan 0,0,0,200 四个数字分别表示距离左、上、右、下边缘的留白像素,以上命令表示将屏幕底部 200px 留白。 恢复原显示区域命令: adb shell wm overscan reset

关闭 USB 调试模式

命令: adb shell settings put global adb_enabled 0 恢复: 用命令恢复不了了,毕竟关闭了 USB 调试 adb 就连接不上 Android 设备了。 去设备上手动恢复吧:「设置」-「开发者选项」-「Android 调试」。

允许/禁止访问非 SDK API

允许访问非 SDK API: adb shell settings put global hidden_api_policy_pre_p_apps 1 adb shell settings put global hidden_api_policy_p_apps 1 禁止访问非 SDK API: adb shell settings delete global hidden_api_policy_pre_p_apps adb shell settings delete global hidden_api_policy_p_apps 不需要设备获得 Root 权限。

命令最后的数字的含义:

0
禁止检测非 SDK 接口的调用。该情况下,日志记录功能被禁用,并且令 strict mode API,即 detectNonSdkApiUsage() 无效。不推荐。

1
仅警告——允许访问所有非 SDK 接口,但保留日志中的警告信息,可继续使用 strick mode API。

2
禁止调用深灰名单和黑名单中的接口。

3
禁止调用黑名单中的接口,但允许调用深灰名单中的接口。

状态栏和导航栏的显示隐藏

本节所说的相关设置对应 Cyanogenmod 里的「扩展桌面」。 命令:

adb shell settings put global policy_control <key-values>

<key-values> 可由如下几种键及其对应的值组成,格式为 <key1>=<value1>:<key2>=<value2>

immersive.full
同时隐藏

immersive.status
隐藏状态栏

immersive.navigation
隐藏导航栏

immersive.preconfirms
?

这些键对应的值可则如下值用逗号组合:

apps
所有应用

*
所有界面

packagename
指定应用

-packagename
排除指定应用

例如:

adb shell settings put global policy_control immersive.full=*

表示设置在所有界面下都同时隐藏状态栏和导航栏。

adb shell settings put global policy_control immersive.status=com.package1,com.package2:immersive.navigation=apps,-com.package3

表示设置在包名为 com.package1 和 com.package2 的应用里隐藏状态栏,在除了包名为 com.package3 的所有应用里隐藏导航栏。

十、实用功能

屏幕截图

截图保存到电脑:

adb exec-out screencap -p > sc.png

如果 adb 版本较老,无法使用 exec-out 命令,这时候建议更新 adb 版本。无法更新的话可以使用以下麻烦点的办法:

先截图保存到设备里:

adb shell screencap -p /sdcard/sc.png

然后将 png 文件导出到电脑:

adb pull /sdcard/sc.png

可以使用 adb shell screencap -h 查看 screencap 命令的帮助信息,下面是两个有意义的参数及含义:

-p
指定保存文件为 png 格式

-d display-id
指定截图的显示屏编号(有多显示屏的情况下)

实测如果指定文件名以 .png 结尾时可以省略 -p 参数;否则需要使用 -p 参数。如果不指定文件名,截图文件的内容将直接输出到 stdout。

另外一种一行命令截图并保存到电脑的方法:

Linux 和 Windows

adb shell screencap -p | sed "s/\r$//" > sc.png

Mac OS X

adb shell screencap -p | gsed "s/\r$//" > sc.png

这个方法需要用到 gnu sed 命令,在 Linux 下直接就有,在 Windows 下 Git 安装目录的 bin 文件夹下也有。如果确实找不到该命令,可以下载 sed for Windows 并将 sed.exe 所在文件夹添加到 PATH 环境变量里。

而在 Mac 下使用系统自带的 sed 命令会报错:

sed: RE error: illegal byte sequence

需要安装 gnu-sed,然后使用 gsed 命令:

brew install gnu-sed

录制屏幕

录制屏幕以 mp4 格式保存到 /sdcard:

adb shell screenrecord /sdcard/filename.mp4

需要停止时按 Ctrl-C,默认录制时间和最长录制时间都是 180 秒。 如果需要导出到电脑:

adb pull /sdcard/filename.mp4

可以使用 adb shell screenrecord –help 查看 screenrecord 命令的帮助信息,下面是常见参数及含义:

--size WIDTHxHEIGHT
视频的尺寸,比如 1280x720,默认是屏幕分辨率。

--bit-rate RATE
视频的比特率,默认是 4Mbps。

--time-limit TIME
录制时长,单位秒。

--verbose
输出更多信息。

重新挂载 system 分区为可写

注:需要 root 权限。

/system 分区默认挂载为只读,但有些操作比如给 Android 系统添加命令、删除自带应用等需要对 /system 进行写操作,所以需要重新挂载它为可读写。

步骤:

  • 进入 shell 并切换到 root 用户权限。 命令: adb shell su
  • 查看当前分区挂载情况。 命令: mount

输出示例:

rootfs / rootfs ro,relatime 0 0
tmpfs /dev tmpfs rw,seclabel,nosuid,relatime,mode=755 0 0
devpts /dev/pts devpts rw,seclabel,relatime,mode=600 0 0
proc /proc proc rw,relatime 0 0
sysfs /sys sysfs rw,seclabel,relatime 0 0
selinuxfs /sys/fs/selinux selinuxfs rw,relatime 0 0
debugfs /sys/kernel/debug debugfs rw,relatime 0 0
none /var tmpfs rw,seclabel,relatime,mode=770,gid=1000 0 0
none /acct cgroup rw,relatime,cpuacct 0 0
none /sys/fs/cgroup tmpfs rw,seclabel,relatime,mode=750,gid=1000 0 0
none /sys/fs/cgroup/memory cgroup rw,relatime,memory 0 0
tmpfs /mnt/asec tmpfs rw,seclabel,relatime,mode=755,gid=1000 0 0
tmpfs /mnt/obb tmpfs rw,seclabel,relatime,mode=755,gid=1000 0 0
none /dev/memcg cgroup rw,relatime,memory 0 0
none /dev/cpuctl cgroup rw,relatime,cpu 0 0
none /sys/fs/cgroup tmpfs rw,seclabel,relatime,mode=750,gid=1000 0 0
none /sys/fs/cgroup/memory cgroup rw,relatime,memory 0 0
none /sys/fs/cgroup/freezer cgroup rw,relatime,freezer 0 0
/dev/block/platform/msm_sdcc.1/by-name/system /system ext4 ro,seclabel,relatime,data=ordered 0 0
/dev/block/platform/msm_sdcc.1/by-name/userdata /data ext4 rw,seclabel,nosuid,nodev,relatime,noauto_da_alloc,data=ordered 0 0
/dev/block/platform/msm_sdcc.1/by-name/cache /cache ext4 rw,seclabel,nosuid,nodev,relatime,data=ordered 0 0
/dev/block/platform/msm_sdcc.1/by-name/persist /persist ext4 rw,seclabel,nosuid,nodev,relatime,data=ordered 0 0
/dev/block/platform/msm_sdcc.1/by-name/modem /firmware vfat ro,context=u:object_r:firmware_file:s0,relatime,uid=1000,gid=1000,fmask=0337,dmask=0227,codepage=cp437,iocharset=iso8859-1,shortname=lower,errors=remount-ro 0 0
/dev/fuse /mnt/shell/emulated fuse rw,nosuid,nodev,relatime,user_id=1023,group_id=1023,default_permissions,allow_other 0 0
/dev/fuse /mnt/shell/emulated/0 fuse rw,nosuid,nodev,relatime,user_id=1023,group_id=1023,default_permissions,allow_other 0 0
找到其中我们关注的带 /system 的那一行:
/dev/block/platform/msm_sdcc.1/by-name/system /system ext4 ro,seclabel,relatime,data=ordered 0 0
  • 重新挂载。 命令:
mount -o remount,rw -t yaffs2 /dev/block/platform/msm_sdcc.1/by-name/system /system

这里的 /dev/block/platform/msm_sdcc.1/by-name/system 就是我们从上一步的输出里得到的文件路径。

如果输出没有提示错误的话,操作就成功了,可以对 /system 下的文件为所欲为了。

查看连接过的 WiFi 密码

注:需要 root 权限。 命令:

adb shell
su
cat /data/misc/wifi/*.conf

输出示例:

network={
	ssid="TP-LINK_9DFC"	scan_ssid=1
	psk="123456789"	key_mgmt=WPA-PSK
	group=CCMP TKIP
	auth_alg=OPEN
	sim_num=1
	priority=13893
}
network={
	ssid="TP-LINK_F11E"	psk="987654321"	key_mgmt=WPA-PSK
	sim_num=1
	priority=17293
}

ssid 即为我们在 WLAN 设置里看到的名称,psk 为密码,key_mgmt 为安全加密方式。

如果 Android O 或以后,WiFi 密码保存的地址有变化,是在 WifiConfigStore.xml 里面

adb shell
su
cat /data/misc/wifi/WifiConfigStore.xml

输出格式:

数据项较多,只需关注 ConfigKey(WiFi 名字)和 PreSharedKey(WiFi 密码)即可/

<stringname="ConfigKey">&quot;Wi-Fi&quot;WPA_PSK</string><stringname="PreSharedKey">&quot;931907334&quot;</string>

设置系统日期和时间

注:需要 root 权限。 命令:

adb shell
su
date -s 20160823.131500

表示将系统日期和时间更改为 2016 年 08 月 23 日 13 点 15 分 00 秒。

重启手机

命令: adb reboot

检测设备是否已 root

命令: adb shell su 此时命令行提示符是 $ 则表示没有 root 权限,是 # 则表示已 root。

使用 Monkey 进行压力测试

Monkey 可以生成伪随机用户事件来模拟单击、触摸、手势等操作,可以对正在开发中的程序进行随机压力测试。 简单用法:

adb shell monkey -p <packagename> -v 500

表示向 <packagename>指定的应用程序发送 500 个伪随机事件。 Monkey 的详细用法参考 官方文档。

开启/关闭 WiFi

注:需要 root 权限。 有时需要控制设备的 WiFi 状态,可以用以下指令完成。 开启 WiFi:

adb root
adb shell svc wifi enable

关闭 WiFi:

adb root
adb shell svc wifi disable

若执行成功,输出为空;若未取得 root 权限执行此命令,将执行失败,输出 Killed。

十一、刷机相关命令

重启到 Recovery 模式 命令:

adb reboot recovery

从 Recovery 重启到 Android 命令:

adb reboot

重启到 Fastboot 模式 命令:

adb reboot bootloader

通过 sideload 更新系统

如果我们下载了 Android 设备对应的系统更新包到电脑上,那么也可以通过 adb 来完成更新。

以 Recovery 模式下更新为例:

  1. 重启到 Recovery 模式。 命令:
adb reboot recovery
  1. 在设备的 Recovery 界面上操作进入 Apply update-Apply from ADB。注:不同的 Recovery 菜单可能与此有差异,有的是一级菜单就有 Apply update from ADB。
  2. 通过 adb 上传和更新系统。 命令:
adb sideload <path-to-update.zip>

十二、安全相关命令

启用/禁用 SELinux
启用 SELinux
adb root
adb shell setenforce 1
禁用 SELinux
adb root
adb shell setenforce 0
启用/禁用 dm_verity
启用 dm_verity
adb root
adb enable-verity
禁用 dm_verity
adb root
adb disable-verity

十三、系统管理命令

Android 系统是基于 Linux 内核的,所以 Linux 里的很多命令在 Android 里也有相同或类似的实现,在 adb shell 里可以调用。本文档前面的部分内容已经用到了 adb shell 命令。

查看进程

命令:

adb shell ps

输出示例:

USER     PID   PPID  VSIZE  RSS     WCHAN    PC        NAME
root      1     0     8904   788   ffffffff 00000000 S /init
root      2     0     0      0     ffffffff 00000000 S kthreadd
...
u0_a71    7779  5926  1538748 48896 ffffffff 00000000 S com.sohu.inputmethod.sogou:classic
u0_a58    7963  5926  1561916 59568 ffffffff 00000000 S org.mazhuang.boottimemeasure
...
shell     8750  217   10640  740   00000000 b6f28340 R ps

各列含义: |列名|含义| |-|-| |USER|所属用户| |PID|进程 ID| |PPID|父进程 ID| |VSIZE|进程虚拟内存大小| |RSS|进程物理内存大小| |WCHAN|进程当前的等待事件| |PC|进程当前执行的指令地址| |NAME|进程名|

查看实时资源占用情况

命令:

adb shell top

输出示例:

User 0%, System 6%, IOW 0%, IRQ 0%
User 3 + Nice 0 + Sys 21 + Idle 280 + IOW 0 + IRQ 0 + SIRQ 3 = 307
  PID PR CPU% S  #THR     VSS     RSS PCY UID      Name 8763  0   3% R     1  10640K   1064K  fg shell    top
  131  0   3% S     1      0K      0K  fg root     dhd_dpc
 6144  0   0% S   115 1682004K 115916K  fg system   system_server
  132  0   0% S     1      0K      0K  fg root     dhd_rxf
 1731  0   0% S     6  20288K    788K  fg root     /system/bin/mpdecision
  217  0   0% S     6  18008K    356K  fg shell    /sbin/adbd
 ...
 7779  2   0% S    19 1538748K  48896K  bg u0_a71   com.sohu.inputmethod.sogou:classic
 7963  0   0% S    18 1561916K  59568K  fg u0_a58   org.mazhuang.boottimemeasure
 ...

各列含义: |列名|含义| |-|-| |PID|进程 ID| |PR|优先级| |CPU%|当前瞬间占用 CPU 百分比| |S|进程状态(R=运行,S=睡眠,T=跟踪/停止,Z=僵尸进程)| |#THR|线程数| |VSS|Virtual Set Size 虚拟耗用内存(包含共享库占用的内存)| |RSS|Resident Set Size 实际使用物理内存(包含共享库占用的内存)| |PCY|调度策略优先级,SP_BACKGROUND/SPFOREGROUND| |UID|进程所有者的用户 ID| |NAME|进程名|

top 命令还支持一些命令行参数,详细用法如下:

Usage: top [ -m max_procs ] [ -n iterations ] [ -d delay ] [ -s sort_column ] [ -t ] [ -h ]
    -m num  最多显示多少个进程
    -n num  刷新多少次后退出
    -d num  刷新时间间隔(单位秒,默认值 5)
    -s col  按某列排序(可用 col 值:cpu, vss, rss, thr)
    -t      显示线程信息
    -h      显示帮助文档

查看进程 UID

有两种方案:

adb shell dumpsys package <packagename> | grep userId=

如:

$ adb shell dumpsys package org.mazhuang.guanggoo | grep userId=
   userId=10394

第二种是通过 ps 命令找到对应进程的 pid 之后 adb shell cat /proc/<pid>/status | grep Uid 如:

$ adb shell
gemini:/ $ ps | grep org.mazhuang.guanggoo
u0_a394   28635 770   1795812 78736 SyS_epoll_ 0000000000 S org.mazhuang.guanggoo
gemini:/ $ cat /proc/28635/status | grep Uid
Uid:    10394   10394   10394   10394
gemini:/ $

其它

如下是其它常用命令的简单描述,前文已经专门讲过的命令不再额外说明: |命令|功能| |-|-| |cat|显示文件内容| |cd|切换目录| |chmod|改变文件的存取模式/访问权限| |df|查看磁盘空间使用情况| |grep|过滤输出| |kill|杀死指定 PID 的进程| |ls|列举目录内容| |mount|挂载目录的查看和管理| |mv|移动或重命名文件| |ps|查看正在运行的进程| |rm|删除文件| |top|查看进程的资源占用情况|

常见问题

启动 adb server 失败 出错提示 error: protocol fault (couldn’t read status): No error 可能原因 adb server 进程想使用的 5037 端口被占用。 解决方案 找到占用 5037 端口的进程,然后终止它。以 Windows 下为例:

netstat -ano | findstr LISTENING
...
TCP    0.0.0.0:5037           0.0.0.0:0              LISTENING       1548
...

这里 1548 即为进程 ID,用命令结束该进程:

taskkill /PID 1548

然后再启动 adb 就没问题了。

Pagination