【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 就没问题了。

【Android基础】系统语言和主题切换

【Android基础】系统语言和主题切换

本文介绍了车载Android系统的语言切换和主题切换的触发以及适配

以下各个api的开发与测试均在车载设备上,可能在手机上不一定生效。

并且主动触发系统环境切换的方法,只有系统权限的app才可以调用。

触发入口调用

一般厂商,都会自己定义一个切换入口,像系统设置,或者桌面的通知页面。这里面去调用系统的api,来达到切换的目的。

主题切换

主题切换可以直接使用系统的UiModeManager即可。

    private val uimodeManager =
        appContext.getSystemService(UI_MODE_SERVICE) as UiModeManager

例如深浅主题切换调用:

// UiModeManager.java
    /**
     * Sets the system-wide night mode.
     * 
     * @param mode the night mode to set
     * @see #getNightMode()
     * @see #setApplicationNightMode(int)
     */
    public void setNightMode(@NightMode int mode) {
        if (mService != null) {
            try {
                mService.setNightMode(mode);
            } catch (RemoteException e) {
                throw e.rethrowFromSystemServer();
            }
        }
    }

我们自定义的触发按钮,按下时可以这样设置:

mMainView.findViewById<Button>(R.id.btn_night).setOnClickListener {
    uimodeManager.nightMode = UiModeManager.MODE_NIGHT_YES
}

mMainView.findViewById<Button>(R.id.btn_light).setOnClickListener {
    uimodeManager.nightMode = UiModeManager.MODE_NIGHT_NO
}

语言切换

通过反射调用ActivityManager的updatePersistentConfiguration方法,即可实现系统语言切换。

    /**
     * 切换语言
     * @param language 语言
     */
    fun changeLanguageSettings(language: Locale) {
        try {
            val activityManagerNative = Class.forName("android.app.ActivityManager")
            val am = activityManagerNative.getMethod("getService").invoke(activityManagerNative)
            val config = am?.javaClass?.getMethod("getConfiguration")
                ?.invoke(am) as Configuration
            config.setLocale(language)
            config.javaClass.getDeclaredField("userSetLocale").setBoolean(config, true)
            am.javaClass.getMethod("updatePersistentConfiguration", config.javaClass)
                .invoke(am, config)
            BackupManager.dataChanged("com.android.providers.settings")
        } catch (e: Exception) {
            e.message?.let { error(it) }
        }
    }

触发按钮调用:

mMainView.findViewById<Button>(R.id.btn_chinese).setOnClickListener {
    changeLanguageSettings(Locale.SIMPLIFIED_CHINESE)
}

mMainView.findViewById<Button>(R.id.btn_english).setOnClickListener {
    changeLanguageSettings(Locale.ENGLISH)
}

适配方app

在上面的app完成触发之后,系统会将环境切换到对应的深浅模式,或者对应的语言状态下,这时候其他的app就需要响应刷新自己的页面。

在开发过程中,可以按下面的几种方法来适配。

资源目录设置

首先不管是Activity应用还是浮窗应用,我们都需要在res资源目录下添加对应的语言和主题的资源目录。

语言目录

以英文为例,新建value-en目录,将翻译之后的strings.xml复制进去即可,字段的名称和中文目录是一致的。

主题目录

深色的主题资源放在带-night后缀的目录下。 例如图片等资源,放置于drawable-mdpi-night目录下,color字段放置于values-night目录下。和语言切换一样,图片,颜色等文件名称和浅色主题一致,切换的时候使用 R 类会自己索引。

逻辑代码

Activity型应用

切换主题和语言时,Activity都会销毁重建,按照触发顺序,大致为:

  • onConfigurationChanged:当系统配置发生变化时,例如屏幕方向改变或主题切换,Activity会首先调用onConfigurationChanged方法。你可以重写这个方法来处理配置变化,例如重新加载资源或更新UI。
  • onSaveInstanceState:在Activity可能被销毁之前,系统会调用onSaveInstanceState方法,允许你保存一些关键的状态信息,以便在Activity重新创建时恢复。
  • 全部的流程如下
{thread:main(2) MainActivity:431 onPause} 
{thread:main(2) MainActivity:495 onStop} 
{thread:main(2) MainActivity:170 onSaveInstanceState} 
{thread:main(2) MainActivity:500 onDestroy} 
{thread:main(2) MainActivity:131 onCreate} 
{thread:main(2) MainActivity:180 initData} 
{thread:main(2) MainActivity:400 initView} 
{thread:main(2) MainActivity:155 onStart} 
{thread:main(2) MainActivity:175 onResume} 
{thread:main(2) MainActivity:481 onWindowFocusChanged} onWindowFocusChanged hasFocus: true

重建之后,Activity会按照提前设置好的资源目录进行资源的获取,自动地刷新界面。切到浅色主题,就会拿取drawable-mdpi目录下的资源,深色主题则会拿取drawable-mdpi-night目录下的资源。

Service加浮窗型应用

在车机开发中,经常会设计一些临时性的悬浮窗app,例如天气,时间,快捷车控等功能。

这类app一般的架构为,开机之后,会启动一个Service,然后在Service中获取WindowManager,来进行悬浮窗的添加移除等管理操作。

// LanguageService.kt
    private val mWmParams = WindowManager.LayoutParams().apply {
        //设置可以显示在状态栏上
        flags = (WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
                or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
                or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                or WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
                or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
                or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL)
        type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
        //设置窗口长宽数据
        width = WindowManager.LayoutParams.WRAP_CONTENT
        height = WindowManager.LayoutParams.WRAP_CONTENT
        gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
        x = 600
        y = 0
        format = PixelFormat.TRANSLUCENT
    }

    private val mWindowManager = appContext.getSystemService(WINDOW_SERVICE) as WindowManager

    private lateinit var mMainView: View

    fun showWindow(){
        mMainView = LayoutInflater.from(appContext).inflate(R.layout.layout_language_change, null, false)

        mWindowManager.addView(mMainView, mWmParams)
    }

不像Activity都是自动化,高层级的悬浮窗app的生命周期比较复杂,需要我们自己去管理。

这时候系统不会自动去重走生命周期,刷新资源了,我们需要手动去切换主题和语言。

首先,需要在service中复写onConfigurationChanged方法。系统在语言和主题切换时,会调用这个方法。主题其他类型的配置切换,例如旋转屏幕等,也会走这个方法。

所以需要设置一个主题(语言)管理类,对比前后的状态,是否这个触发的类型是主题(语言)切换。

监听到了变化之后,有两种方案:

手动刷新置换资源

第一种,对于界面简单的浮窗界面,可以直接在onConfigurationChanged中,重新加载资源,然后重新设置布局。这种方法不用考虑窗口的状态,直接对每个View进行定点刷新,不容易出问题。

语言切换:

    override fun onConfigurationChanged(newConfig: Configuration) {
        super.onConfigurationChanged(newConfig)
        LogUtils.i(TAG, "onConfigurationChanged")
        mMainView.findViewById<Button>(R.id.btn_chinese).text = getString(R.string.chinese)
        mMainView.findViewById<Button>(R.id.btn_english).text = getString(R.string.english)
    }

主题切换:

    override fun onConfigurationChanged(newConfig: Configuration) {
        super.onConfigurationChanged(newConfig)
        LogUtils.i(TAG, "onConfigurationChanged")
        mMainView.setBackgroundColor(
            ResourcesCompat.getColor(this.resources, R.color.theme_test, null)
        )
        mMainView.findViewById<ImageView>(R.id.iv_close)
            .setImageResource(R.drawable.ic_close_dialog)
    }
置空重建

第二种方案比较适合复杂的View,内部组件众多,挨个手动替换比较麻烦。

这时就可以模仿Activity的切换方式,直接移除掉之前的view,将其置空后,重新创建一个view,设置好子View的监听方法,然后重新添加到windowManager中。

    override fun onConfigurationChanged(newConfig: Configuration) {
        super.onConfigurationChanged(newConfig)
        LogUtils.i(TAG, "onConfigurationChanged")
        mWindowManager.removeView(mMainView)
        mMainView = null
        mMainView =
            LayoutInflater.from(appContext)
                .inflate(R.layout.layout_language_change, null, false)
        initViews()
        mWindowManager.addView(mMainView, mWmParams)
    }

这种方法简单粗暴,但是必须要管控好窗口的状态和置空的时机,否则可能会导致内存泄漏。

而且这种方法也会导致窗口的闪烁,最好在系统切换时有一个过度的效果动画。

【Android基础】系统数据库通信使用方式

【Android基础】系统数据库通信使用方式

本文介绍了Android系统数据库通信使用方式

Settings.System数据库本来是用来存储用户偏好设置的机制,在车载系统开发时,由于车辆的设置信号都发给了底层的控制器ECU等去记忆了,这一块更多的是被用来做键值对存储和 IPC跨进程通信使用。

存储位置和权限

在Android中,用户的默认设置和偏好设置是存在数据库中,在Android 6.0 以前,settings的数据是存在settings.db中,系统可以通过sqlite来进行读写。 这样的话,所有的第三方应用都可以对settings.db进行操作,修改用户设置的数据。存储位置为,{system, secure, global} 对应的是目录 /data/data/com.android.providers.settings/databases/settings.db 的三个表。

所以在 在Android 6.0版本以后,SettingsProvider被重构,从安全和性能等方面考虑,把SettingsProvider中原本保存在settings.db中的数据,目前全部保存在XML文件中。一般位于 /data/system/users/0 目录下,该目录的settings_global.xml,settings_secure.xml和settings_system.xml三个xml文件就是SettingsProvider中的数据文件。

Settingsprovider中对数据也进行了分类,分别是Global、System、Secure、Ssaid四种类型,说明如下:

  • Global:所有的偏好设置对系统的所有用户公开,第三方APP有读没有写的权限;
  • System:包含各种各样的用户偏好系统设置,第三方APP有读没有写的权限;
  • Secure:安全性的用户偏好系统设置,第三方APP有读没有写的权限。
  • 另外,还有一个不被熟知的Ssaid 表:此表包括所有应用的id;这样的话,只是可以从文件权限类型来做权限的管控,可以让第三方APP有读没有写的权限,或者直接不给读写权限等

车载使用很多,可以当作键值对存储使用,也可以多进程共享发通知使用。

第三方APP使用

读取数据

使用adb写入一个测试字段

montecarlo:/ # settings put global audio_test_result 234

使用Settings.Global.getInt来读取这个字段,可以看到显示。

CoroutineScope(Dispatchers.IO).launch {
    val tesetData = Settings.Global.getInt(this@MainActivity.contentResolver, "audio_test_result")
    Log.i(TAG, "getGlobalData : $tesetData")
}

监听

除了单次读取数据库的值,还可以通过 contentResolver.registerContentObserver 来添加持续的监听。

private val observer = object : ContentObserver(null) {
    override fun onChange(selfChange: Boolean) {
        val stringData =
            Settings.System.getString(
                appContext.contentResolver,
                ACTION_MUTUAL_NOTIFY
            )
        debugLog("onChange: data:$stringData")
    }
}

fun registerSystemSettingOberver() {
    appContext.contentResolver.registerContentObserver(
        Settings.System.getUriFor(ACTION_MUTUAL_NOTIFY),
        true,
        observer
    )
}

fun unRegisterSystemSettingOberver() {
    appContext.contentResolver.unregisterContentObserver(
        observer
    )
}

尝试写入

CoroutineScope(Dispatchers.IO).launch {
    Settings.Global.putInt(this@MainActivity.contentResolver, "audio_test_result", 2415)
}

报错信息可以看到系统拒绝了第三方APP的写入操作:

Process: com.example.composedemo, PID: 28630
java.lang.SecurityException: Permission denial: writing to settings requires:android.permission.WRITE_SECURE_SETTINGS
at android.os.Parcel.createExceptionOrNull(Parcel.java:3011)
at android.os.Parcel.createException(Parcel.java:2995)
at android.os.Parcel.readException(Parcel.java:2978)
at android.database.DatabaseUtils.readExceptionFromParcel(DatabaseUtils.java:190)
at android.database.DatabaseUtils.readExceptionFromParcel(DatabaseUtils.java:142)
at android.content.ContentProviderProxy.call(ContentProviderNative.java:732)
at android.provider.Settings$NameValueCache.putStringForUser(Settings.java:3017)
at android.provider.Settings$Global.putStringForUser(Settings.java:16970)
at android.provider.Settings$Global.putString(Settings.java:16811)
at android.provider.Settings$Global.putInt(Settings.java:17041)
at com.example.composedemo.MainActivity$onCreate$1.invokeSuspend(MainActivity.kt:42)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:108)
at kotlinx.coroutines.internal.LimitedDispatcher$Worker.run(LimitedDispatcher.kt:115)
at kotlinx.coroutines.scheduling.TaskImpl.run(Tasks.kt:103)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:793)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:697)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:684)
Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@d6b0268, Dispatchers.IO]
Caused by: android.os.RemoteException: Remote stack trace:
at com.android.providers.settings.SettingsProvider.enforceWritePermission(SettingsProvider.java:2299)
at com.android.providers.settings.SettingsProvider.mutateGlobalSetting(SettingsProvider.java:1452)
at com.android.providers.settings.SettingsProvider.insertGlobalSetting(SettingsProvider.java:1406)
at com.android.providers.settings.SettingsProvider.call(SettingsProvider.java:450)
at android.content.ContentProvider.call(ContentProvider.java:2511)

系统APP的通信

系统app可以写入系统数据库的内容,很多场景下也被用来作为 IPC 多进程通信的方式。

有两种使用场景

数据传输

一方来修改需要传输的值,另一方监听变化读取获取。

修改数据库的值,以字符串数据为例,调用 Settings.System.putString()

修改方:

fun changeSystemSettingData() {
    val tsStringData = "test string"
    Settings.System.putString(
        appContext.contentResolver,
        ACTION_MUTUAL_NOTIFY,
        tsStringData
    )
}

接收方,这里和第三方APP没有区别:

private val observer = object : ContentObserver(null) {
    override fun onChange(selfChange: Boolean) {
        val stringData =
            Settings.System.getString(
                appContext.contentResolver,
                ACTION_MUTUAL_NOTIFY
            )
        debugLog("onChange: data:$stringData")
    }
}

fun registerSystemSettingOberver() {
    appContext.contentResolver.registerContentObserver(
        Settings.System.getUriFor(ACTION_MUTUAL_NOTIFY),
        true,
        observer
    )
}

fun unRegisterSystemSettingOberver() {
    appContext.contentResolver.unregisterContentObserver(
        observer
    )
}

单次通知式

这种类似于广播,做触发式的逻辑,但是希望点对点建立通信协议,互相发通知。

一般是在一个单独的Service里面加入监听和发送通知的逻辑,同时还要屏蔽自己发出去的通知。

修改数据库的值,以字符串数据为例,调用 Settings.System.putString()

需要注意的是,这里的onChange回调是只有在变化时才会调用的。如果两次写入的是一样的值,接收方是收不到通知的。

所以这种连续发通知式的调用,再调用写入之后,还要调用notifyChange()方法。

fun changeSystemSettingData() {
    val tsStringData = "same string"
    Settings.System.putString(
        appContext.contentResolver,
        ACTION_MUTUAL_NOTIFY,
        tsStringData
    )
    appContext.contentResolver.notifyChange(
        Settings.System.getUriFor(ACTION_MUTUAL_NOTIFY), null
    )
}

如果是两方需要互相发通知怎么办呢,自己发出去的修改,自己的observer也收到了。

这时候我们打开notifyChange的源码看一看:

    /**
     * Notify registered observers that a row was updated and attempt to sync
     * changes to the network.
     * <p>
     * To observe events sent through this call, use
     * {@link #registerContentObserver(Uri, boolean, ContentObserver)}.
     * <p>
     * Starting in {@link android.os.Build.VERSION_CODES#O}, all content
     * notifications must be backed by a valid {@link ContentProvider}.
     *
     * @param uri The uri of the content that was changed.
     * @param observer The observer that originated the change, may be
     *            <code>null</null>. The observer that originated the change
     *            will only receive the notification if it has requested to
     *            receive self-change notifications by implementing
     *            {@link ContentObserver#deliverSelfNotifications()} to return
     *            true.
     */
    public void notifyChange(@NonNull Uri uri, @Nullable ContentObserver observer) {
        notifyChange(uri, observer, true /* sync to network */);
    }

在调用notifyChange的时候,将第二个参数observer传入自己这方的监听器,同时observer在继承的时候,需要复写deliverSelfNotifications()方法返回true,这样在自己发通知的时候,onChangee方法的回调selfChange标志位会被正确的置为true,可以用以筛选。

private val observer = object : ContentObserver(null) {
    override fun onChange(selfChange: Boolean) {
        val stringData =
            Settings.System.getString(
                appContext.contentResolver,
                ACTION_MUTUAL_NOTIFY
            )
        debugLog("onChange: data:$stringData selfchange:$selfChange")
    }

    override fun deliverSelfNotifications() = true
}

【Android基础】数据存储方案总结

【Android基础】数据存储方案总结

本文为android系统上各种存储数据的方式的总结

ContentProvider

见另一篇文章: 【2022-9-12-四大组件之ContentProvider】

内部存储(Internal Storage)

内部存储用于存储应用私有的数据,其他应用无法访问。数据存储在应用的内部目录中,注意,此处的文件会随应用的卸载而删除。适合存储应用的缓存文件,配置文件等。

路径: /storage/emulated/0/Android/data/${packageName}/files

存储代码示例,以下是一个app管理功能中存储应用图标Drawable文件到内部缓存目录:

/**
 * 存储图标png到app内部目录
 */
private fun saveDrawableToFile(context: Context, drawable: Drawable, fileName: String) {
    val bitmap = drawable.toBitmap()
    val file = File(context.getExternalFilesDir(null), "$fileName.png")
    val fos = FileOutputStream(file)
    infoLog("path:${file.absolutePath}")
    bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)
    fos.flush()
    fos.close()
}

外部存储(External Storage)

用于存储公共的、可共享的数据,其他应用可以访问。数据存储在设备的外部存储设备(如SD卡)上,即使应用被卸载,数据仍然保留。适合存储用户生成的文件,如照片、视频等。

路径一般为sdcard内。此处的存储操作想要顺利完成,一般需要手动申请运行时权限。

存储代码示例,往sdcard的Pictures目录下写一张图片:

/**
 * 保存一个bitmap到本地sdcard的Pictures目录
 */
fun saveImageToGallery(
    context: Context,
    bitmap: Bitmap,
    filename: String = "FilmSimulation.jpg",
) {
    val values = ContentValues().apply {
        put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
        put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") // 文件类型
        put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
    }
    runCatching {
        context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
            ?.let {
                context.contentResolver.openOutputStream(it)?.apply {
                    bitmap.compress(Bitmap.CompressFormat.JPEG, 100, this)
                    flush()
                    close()
                }
                context.contentResolver.notifyChange(it, null)
            }
    }.onFailure {
        Log.e(TAG, "save image error:${it.message}")
    }
}

数据库存储

数据库存储用于存储结构化的数据,如用户信息、聊天记录等。数据库存储可以支持复杂的查询和关联操作。

数据库存储的路径一般在

/storage/emulated/0/Android/data/${packageName}/databases

像下面的用以举例的 Demo,数据库路径如下:

emu64xa:/data/data/com.example.roomdemo/databases # ls
camera_database  camera_database-shm  camera_database-wal

Room数据库介绍

SQLite数据库

Android平台选取了SqLlite数据库作为结构化数据库的存储方案。

用于存储结构化的数据,如用户信息、聊天记录等。轻量级的关系型数据库,支持SQL查询,适合存储大量的结构化数据。应用可极大地受益于在本地保留这些数据。最常见的使用场景是缓存相关的数据,这样一来,当设备无法访问网络时,用户仍然可以在离线状态下浏览该内容。

长期以来,SQLite 数据库繁杂的使用体验,也让开发者们感到困惑。

Room 持久性库在 SQLite 上提供了一个抽象层,以便在充分利用 SQLite 的强大功能的同时,能够流畅地访问数据库。具体来说,Room 具有以下优势:

  • 提供针对 SQL 查询的编译时验证。
  • 提供方便注解,可最大限度减少重复和容易出错的样板代码。
  • 简化了数据库迁移路径。

使用Room数据库

首先添加依赖库,app级的 build.gradle.kts 依赖添加:

val room_version = "2.6.1"
implementation("androidx.room:room-runtime:$room_version")

/**
  * KSP (Kotlin Symbol Processing)是以 Kotlin 优先的 kapt 替代方案。
  * KSP 可直接分析 Kotlin 代码,使得速度提高多达 2 倍。
  * 此外,它还可以更好地了解 Kotlin 的语言结构。
  */
ksp("androidx.room:room-compiler:$room_version")

Kapt背景知识: Kapt可以将 Java 注解处理器与 Kotlin 代码搭配使用,即使这些处理器没有特定的 Kotlin 支持也是如此。方法是从 Kotlin 文件生成 Java 桩,然后处理器就可以读取这些桩。生成桩是一项成本高昂的操作,并且对构建速度有很大影响。

而KSP 可以说是Kapt的升级替代方案,它是一个 Kotlin 编译器插件,它可以在编译时读取和分析 Kotlin 代码,然后生成 Java 代码。KSP 可以直接分析 Kotlin 代码,而不需要通过 Java 桩。这使得 KSP 可以更好地了解 Kotlin 的语言结构。

如果没有提前添加ksp插件,上面的依赖引入应该是报红的。

添加KSP插件

  1. 项目顶级build.gradle.kts文件:
plugins {
    id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false
}
  1. app级的build.gradle.kts文件:
plugins {
    id("com.google.devtools.ksp")
}

Room数据库组件

Room 包含三个主要组件:

  • 数据库类,用于保存数据库并作为应用持久性数据底层连接的主要访问点。
  • 数据实体,用于表示应用的数据库中的表。
  • 数据访问对象 (DAO),为您的应用提供在数据库中查询、更新、插入和删除数据的方法。

也就是说,至少需要定义三个类,才可以使用Room数据库来存储数据。

数据实体

数据实体是数据库中表的映射。数据实体是一个类,需要添加 @Entity 注解。

@Entity
data class Camera (
    @PrimaryKey
    val cameraId: Int,
    @ColumnInfo(name = "brand_name") val brandName: String,
    @ColumnInfo(name = "camera_model") val cameraModel: String,
)

数据访问对象 (DAO)

数据访问对象 (DAO) 是用于在数据库中执行查询和更新的接口。数据访问对象 (DAO) 是一个接口,需要添加 @Dao 注解。

@Dao
interface CameraDao {
    @Query("SELECT * FROM camera")
    fun getAll(): List<Camera>

    @Query("SELECT * FROM camera WHERE cameraId IN (:cameraIds)")
    fun loadAllByIds(cameraIds: IntArray): List<Camera>

    @Query("SELECT * FROM camera WHERE brand_name LIKE :first AND " + "camera_model LIKE :last LIMIT 1")
    fun findByName(first: String, last: String): Camera
    
    @Insert
    fun insertAll(vararg cameras: Camera)

    @Delete
    fun delete(camera: Camera)
}

数据库类

数据库类是应用的入口点,用于访问应用的数据库。

数据库类为应用提供与该数据库关联的 DAO 的实例。反过来,应用可以使用 DAO 从数据库中检索数据,作为关联的数据实体对象的实例。此外,应用还可以使用定义的数据实体更新相应表中的行,或者创建新行供插入。

数据库类是一个抽象类,需要继承它,并且需要添加 @Database 注解。

@Database 注解有两个参数:entities 和 version。entities 是一个数组,用于指定数据库中包含的实体类。version 是一个整数,用于指定数据库的版本号。

@Database(entities = [Camera::class], version = 1)
abstract class CameraDatabase : RoomDatabase() {
    abstract fun cameraDao(): CameraDao
}

数据库使用

数据库类是应用的入口点,用于访问应用的数据库。

使用 Room.databaseBuilder 方法来创建数据库实例。然后,可以使用 CameraDatabase 中的抽象方法获取 DAO 的实例,转而可以使用 DAO 实例中的方法与数据库进行交互。

下面是Demo测试代码,注意要在 IO 线程中进行数据库的创建,读写等操作:

CoroutineScope(Dispatchers.IO).launch {
    val db = Room.databaseBuilder(appContext, CameraDatabase::class.java, "camera_database").build()
    val camereDao = db.cameraDao()
    camereDao.insertAll(
        Camera(1000, "Canon", "EOS R6 II"),
        Camera(1001, "Sony", "A9 II"),
        Camera(1002, "LUMIX", "S5M2K")
    )
    delay(3000L)
    Log.i(TAG, "room database test: ${camereDao.getAll()}")
}

四种流行的键值对存储

前三种方案对比结论,来自扔物线朱凯大佬的测试数据。

SharedPreferences

如果您有想要保存的相对较小键值对集合,则可以使用 SharedPreferences API。SharedPreferences 对象指向包含键值对的文件,并提供读写这些键值对的简单方法。每个 SharedPreferences 文件均由框架进行管理,可以是私有文件,也可以是共享文件。

键值对的存储在移动开发里非常常见。比如深色模式的开关、软件语言、字体大小,这些用户偏好设置,很适合用键值对来存。

SharedPreferences 使用起来很简单,但是有性能问题,容易卡顿,甚至有时候会出现 ANR。

MMKV

腾讯开源了一个叫做 MMKV 的项目。它和 SP 一样,都是做键值对存储的,可是它的性能比 SP 强很多。

MMKV的开发背景:

微信在遇到一些无法处理的字符的时候,会出现崩溃的问题,而微信为了及时地找出导致崩溃的字符或者字符串,所有的对话内容在显示之前,先保存到磁盘再显示。而且防止崩溃之后数据还没存好,必须要在主线程去完成这个写操作,耗时就绝对无法避免。一帧的时间也就 16 毫秒,在16 毫秒里来个写磁盘的操作,用户很可能就会感受到一次卡顿。如果用户点开了一个活跃的群,这个群里有几百条没看过的消息。而如果把这几条消息都记录下来,是不是每条消息的记录都会涉及一次写磁盘的操作?这几次写磁盘行为,是发生在同一帧里的,所以在这一帧里因为记录文字而导致的主线程耗时,也会相比起刚才的例子翻上好几倍,卡顿时间就同样也会翻上好几倍。

最终微信找到了解决方案。使用了一种叫做内存映射(mmap())的底层方法。

它可以让系统为你指定的文件开辟一块专用的内存,这块内存和文件之间是自动映射、自动同步的关系,你对文件的改动会自动写到这块内存里,对这块内存的改动也会自动写到文件里。

有了这一层内存作为中间人,我们就可以用「写入内存」的方式来实现「写入磁盘」的目标了。它在程序崩溃的时候,并不会随着进程一起被销毁掉,而是会继续有条不紊地把它里面还没同步完的内容同步到它所映射的文件里面去。

MMKV缺点:

  • 字符串大数据比较慢
  • 丢数据

MMKV 优势:

  • 写速度极快
  • 支持多进程

SP和DataStore对比

DataStore 被创造出来的目标就是替代 SharedPreferences,而它解决的 SharedPreferences 最大的问题有两点:一是性能问题,二是回调问题。

先说性能问题:SharedPreferences 虽然可以用异步的方式来保存更改,以此来避免 I/O 操作所导致的主线程的耗时。

但在 Activity 启动和关闭的时候,Activity 会等待这些异步提交完成保存之后再继续,这就相当于把异步操作转换成同步操作了,从而会导致卡顿甚至 ANR(程序未响应)。

这是为了保证数据的一致性而不得不做的决定,但它也确实成为了 SharedPreferences 的一个弱点。

但是,SharedPreferences 所导致的卡顿和 ANR,是非常低概率的事件。

读取文件卡顿

其实除了写数据时的卡顿,SharedPreferences 在读取数据的时候也会卡顿。

虽然它的文件加载过程是在后台进行的,但如果代码在它加载完成之前就去尝试读取键值对,线程就会被卡住,直到文件加载完成,而如果这个读取的过程发生在主线程,就会造成界面卡顿,并且数据文件越大就会越卡。

这种卡顿,不是 SharedPreferences 独有的,MMKV 也是存在的,因为它初始化的过程同样也是从磁盘里读取文件,而且是一股脑把整个文件读完,所以耗时并不会比 SharedPreferences 少。

而 DataStore,就没有这种问题。DataStore 不管是读文件还是写文件,都是用的协程在后台进行读写,所有的 I/O 操作都是在后台线程发生的,所以不论读还是写,都不会卡主线程。

DataStore回调更方便

DataStore 解决的 SharedPreferences 的另一个问题就是回调。

SharedPreferences 如果使用同步方式来保存更改commit(),会导致主线程的耗时;

但如果使用异步的方式,给它加回调又很不方便,也就是如果你想做一些「等这个异步提交完成之后再怎么怎么样」的工作,会很麻烦。

而 DataStore 由于是用协程来做的,线程的切换是非常简单的,你就把「保存完成之后做什么」直接写在保存代码的下方就可以了,很直观、很简单。

对比来说,MMKV 虽然没有使用协程,但是它太快了,所以大多数时候并不需要切线程也不会卡顿。

三种方式的总结

区别大概就是这么些区别了,大致总结一下就是:

如果你有多进程支持的需求,可以选择MMKV,也可以选择DataStore(1.1.0版本新增);如果你有高频写入的需求,你也应该优先考虑 MMKV。但如果你使用 MMKV,一定要知道它是可能丢失数据的,不过概率很低就是了,所以你要在权衡之后做好决定:是自行实现数据的备份和恢复方案,还是直接接受丢数据的事实,在每次丢失数据之后帮用户把相应的数据进行初始化。

DataStore 在任何时候都不会卡顿,而 MMKV 在写大字符串和初次加载文件的时候是可能会卡顿的,而且初次加载文件的卡顿不是概率性的,只要文件大到了引起卡顿的程度,就是 100% 的卡顿。

三种方案提取的工具类

在我之前研究AOSP redfin平台的项目的时候,在CommonHelper库里面对这三种存储方式都做了一个很简单的工具类。

SharedPreferences

/**
 * SharedPreference存储工具类
 * 不会丢数据
 * 但是加回调不方便
 */
object SPHelper {

    private lateinit var share: SharedPreferences

    private lateinit var editor: SharedPreferences.Editor

    private const val SHARED_NAME = "SPHelper"

    fun init(context: Context) {
        share = context.getSharedPreferences(SHARED_NAME, Context.MODE_PRIVATE);
        editor = share.edit();
    }

    // 采用同步保存,获取保存成功与失败的result
    fun putString(key: String, value: String?): Boolean {
        infoLog("putString key: $key, value: $value")
        editor.putString(key, value)
        return editor.commit()
    }

    fun getString(key: String): String? {
        val value = share.getString(key, null)
        infoLog("getString key: $key, value: $value")
        return value
    }

    fun getString(key: String, defaultValue: String): String {
        val value = share.getString(key, null)
        infoLog("getString key: $key, value: $value, defaultValue: $defaultValue")
        return value ?: defaultValue
    }

    fun putLong(key: String?, value: Long): Boolean {
        infoLog("putLong key: $key, value: $value")
        editor.putLong(key, value)
        return editor.commit()
    }

    fun putFloat(key: String?, value: Float): Boolean {
        infoLog("putFloat key: $key, value: $value")
        editor.putFloat(key, value)
        return editor.commit()
    }

    fun putInt(key: String?, value: Int): Boolean {
        infoLog("putInt key: $key, value: $value")
        editor.putInt(key, value)
        return editor.commit()
    }

    fun putBoolean(key: String?, value: Boolean): Boolean {
        infoLog("putBoolean key: $key, value: $value")
        editor.putBoolean(key, value)
        return editor.commit()
    }

    fun getLong(key: String?): Long {
        val value = share.getLong(key, -1)
        infoLog("getLong key: $key, value: $value")
        return value
    }

    fun getInt(key: String?, defaultValue: Int): Int {
        val value = share.getInt(key, defaultValue)
        infoLog("getInt key: $key, value: $value")
        return value
    }

    fun getFloat(key: String?, defaultValue: Float): Float {
        val value = share.getFloat(key, defaultValue)
        infoLog("getFloat key: $key, value: $value")
        return value
    }

    fun getBoolean(key: String?, defaultValue: Boolean): Boolean {
        val value = share.getBoolean(key, defaultValue)
        infoLog("getBoolean key: $key, value: $value")
        return value
    }

    fun removeSharedPreferenceByKey(key: String?): Boolean {
        infoLog("removeSharedPreferenceByKey key: $key")
        editor.remove(key)
        return editor.commit()
    }
}

MMKV

/**
 * 最适合同步写入小数据
 * 支持多进程,高频写入性能好
 * 但有可能丢数据
 */
object MMKVHelper {

    private lateinit var mmkv: MMKV

    fun init(context: Context,databaseId: String, isMultiProcess: Boolean) {
        val rootDir = MMKV.initialize(context)
        infoLog("MMKV rootDir: $rootDir")
        mmkv =
            if (isMultiProcess) MMKV.mmkvWithID(databaseId, MMKV.MULTI_PROCESS_MODE)
            else MMKV.mmkvWithID(databaseId)
    }

    fun putString(key: String, value: String?) {
        infoLog("putString key: $key, value: $value")
        mmkv.encode(key, value)
    }

    fun getString(key: String): String? {
        val value = mmkv.decodeString(key)
        infoLog("getString key: $key, value: $value")
        return value
    }

    fun getString(key: String, defaultValue: String): String {
        val value = mmkv.decodeString(key)
        infoLog("getString key: $key, value: $value, defaultValue: $defaultValue")
        return value ?: defaultValue
    }

    fun putLong(key: String?, value: Long) {
        infoLog("putLong key: $key, value: $value")
        mmkv.encode(key, value)
    }

    fun putFloat(key: String?, value: Float) {
        infoLog("putFloat key: $key, value: $value")
        mmkv.encode(key, value)
    }

    fun putInt(key: String?, value: Int) {
        infoLog("putInt key: $key, value: $value")
        mmkv.encode(key, value)
    }

    fun putBoolean(key: String?, value: Boolean) {
        infoLog("putBoolean key: $key, value: $value")
        mmkv.encode(key, value)
    }

    fun getLong(key: String?): Long {
        val value = mmkv.decodeLong(key, -1)
        infoLog("getLong key: $key, value: $value")
        return value
    }

    fun getInt(key: String?, defaultValue: Int): Int {
        val value = mmkv.decodeInt(key, defaultValue)
        infoLog("getInt key: $key, value: $value")
        return value
    }

    fun getFloat(key: String?, defaultValue: Float): Float {
        val value = mmkv.decodeFloat(key, defaultValue)
        infoLog("getFloat key: $key, value: $value")
        return value
    }

    fun getBoolean(key: String?, defaultValue: Boolean): Boolean {
        val value = mmkv.decodeBool(key, defaultValue)
        infoLog("getBoolean key: $key, value: $value")
        return value
    }
}

DataStore

/**
 * 谷歌推荐的最新存储方式
 * 协程实现,可以方便地获取存储的结果回调
 */
object DataStoreHelper {
    // 定义一个 DataStore 实例
    val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "data_store_settings")

    private lateinit var outDataStore: DataStore<Preferences>

    /**
     * 初始化,使get和set不受Context域限制
     */
    fun init(context: Context) {
        outDataStore = context.dataStore
    }

    // 定义一个 suspend 函数,用于从 DataStore 中读取数据
    suspend fun <T> getData(key: Preferences.Key<T>, defaultValue: T): T {
        return (outDataStore.data.first()[key] ?: defaultValue)
    }

    // 定义一个 suspend 函数,用于将数据保存到 DataStore 中
    suspend fun <T> saveData(key: Preferences.Key<T>, value: T) {
        outDataStore.edit { preferences ->
            preferences[key] = value
        }
    }
}

使用:

CoroutineScope(Dispatchers.IO).launch {
    val INT_PREF_KEY = intPreferencesKey("IntKey")
    val FLOAT_PERF_KEY = floatPreferencesKey("FloatKey")
    val STRING_PREF_KEY = stringPreferencesKey("SteringKey")

    DataStoreHelper.saveData(INT_PREF_KEY, 45)
    DataStoreHelper.saveData(FLOAT_PERF_KEY, 45.0f)
    DataStoreHelper.saveData(STRING_PREF_KEY, "45")
    delay(1000L)
    // 拿取刚刚存的值
    Log.i(TAG, "intData: ${DataStoreHelper.getData(INT_PREF_KEY, -1)}")
    Log.i(TAG, "floatData: ${DataStoreHelper.getData(FLOAT_PERF_KEY, -1f)}")
    Log.i(TAG, "stringData: ${DataStoreHelper.getData(STRING_PREF_KEY, "default")}")
}

Setting.System系统数据库

这里在车载上使用的更多,单独写了一篇总结:

【Android系统数据库通信使用方式】

【Android基础】Fragment组件进阶

【Android基础】Fragment组件进阶

本文介绍了Fragment组件的进阶知识,包括Fragment的生命周期、Fragment间的通信方式等。

Fragment 是 Android UI 开发中一个非常重要的组件,用于构建模块化、可复用且灵活的用户界面。

Fragment 可以被视为一个Activity 的一部分或行为。它拥有自己的生命周期、布局和输入事件,但它必须托管在一个 Activity 中。一个 Activity 可以包含一个或多个 Fragment,也可以在不同的 Activity 中复用同一个 Fragment

Fragment 的主要作用

  1. 模块化 UI: 可以将一个复杂的用户界面分解成独立的、可管理的模块。
  2. UI 可复用性: 可以在不同的 Activity 或同一 Activity 的不同配置(如横竖屏)中复用 Fragment。
  3. 适应不同屏幕尺寸: 尤其在平板电脑等大屏幕设备上,可以同时显示多个 Fragment,例如列表-详情布局(List-Detail Flow)。
  4. 简化 Activity 代码: 将 UI 逻辑和行为从 Activity 中分离出来,使 Activity 变得更轻量和专注于协调。
  5. 支持回退栈: 可以像 Activity 一样管理 Fragment 的回退栈,实现前进和后退导航。

生命周期

Fragment 的生命周期与它所依附的 Activity 的生命周期紧密相关。理解这些回调方法对于正确管理 Fragment 的状态至关重要。

以下是 Fragment 生命周期中几个关键的方法及其大致顺序:

  • onAttach(): 当 Fragment 与 Activity 关联时调用。此时可以获取到 Context 对象。
  • onCreate(): Fragment 被创建时调用。在这里进行非 UI 的初始化,如变量设置、数据加载等。
  • onCreateView(): 创建 Fragment 的用户界面(View)。在这里膨胀(inflate)布局并返回根视图。
  • onViewCreated(): onCreateView() 返回后调用。在这里可以初始化 View 组件,设置监听器等。
  • onActivityCreated(): 当宿主 Activity 的 onCreate() 方法完成时调用。可以在这里执行依赖于 Activity 已创建的代码。
  • onStart(): Fragment 可见时调用。
  • onResume(): Fragment 获得焦点并可与用户交互时调用。
  • onPause(): Fragment 失去焦点,但仍然部分可见时调用(例如,另一个 Fragment 覆盖了它)。
  • onStop(): Fragment 不再可见时调用。
  • onDestroyView(): Fragment 的视图被移除时调用。在这里释放与 View 相关的资源。
  • onDestroy(): Fragment 实例被销毁时调用。在这里释放所有非 View 相关的资源。
  • onDetach(): Fragment 与 Activity 解除关联时调用。

使用流程

1. 创建 Fragment

创建一个继承自 androidx.fragment.app.Fragment 的 Java/Kotlin 类,并通常重写 onCreateView() 方法来提供其布局:

class MyFragment : Fragment() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Fragment 初始化逻辑
    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // 膨胀 Fragment 的布局
        return inflater.inflate(R.layout.fragment_my, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // 初始化 View 组件
        // val myTextView = view.findViewById<TextView>(R.id.myTextView)
        // myTextView.text = "Hello from Fragment!"
    }
}

对应的 fragment_my.xml 布局文件:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/myTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="My Fragment Content"
        android:textSize="24sp"
        android:layout_gravity="center" />

</FrameLayout>

2. 将 Fragment 添加到 Activity

有两种主要方式将 Fragment 添加到 Activity 中:

a. 在布局 XML 中声明

你可以在 Activity 的布局 XML 文件中直接声明一个 Fragment。这是 静态添加 Fragment 的方式。

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

    <fragment
        android:id="@+id/my_static_fragment"
        android:name="com.example.yourapp.MyFragment" // 完整的 Fragment 类名
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>

这种方式下,Fragment 的生命周期会与 Activity 的生命周期紧密耦合,并且在 Activity 创建时就被实例化。

b. 运行时动态添加(推荐)

通过 FragmentManagerFragmentTransaction 在 Activity 运行时动态添加、移除、替换或显示/隐藏 Fragment。这是最常用的方式,因为它提供了更大的灵活性。

class MainActivity : AppCompatActivity() {

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

        // 检查 Fragment 是否已经添加,避免重复添加(例如在配置变化后)
        if (savedInstanceState == null) {
            val fragmentManager: FragmentManager = supportFragmentManager
            val fragmentTransaction: FragmentTransaction = fragmentManager.beginTransaction()

            val myFragment = MyFragment()
            // 将 Fragment 添加到一个容器视图中 (例如一个 FrameLayout)
            fragmentTransaction.add(R.id.fragment_container, myFragment)
            // fragmentTransaction.addToBackStack(null) // 可选:添加到回退栈
            fragmentTransaction.commit()
        }
    }
}

初始化添加时最好是先行检查一下 savedInstanceState 是否为 null,避免重复添加 Fragment。如果是系统的配置变更,如语言和主题,我们知道Activity会自动重建,而FagmentManager 会在 Activity 重建时,自动恢复那些在 Activity 被销毁前已经存在的 Fragment 实例。如果此时又调用了 fragmentTransaction.add() 方法添加 Fragment,就会导致重复添加,引发异常,界面内容可能会重叠显示多个fragment。

对应的 activity_main.xml 布局需要一个用于容纳 Fragment 的容器:

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/fragment_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

Fragment 的通信

由于 Fragment 之间是独立的,它们之间以及与宿主 Activity 之间需要明确的通信机制。

  1. Fragment 到 Activity:

    • 推荐方式: 定义一个接口,让 Activity 实现该接口。Fragment 通过 onAttach() 获取 Activity 实例并将其转换为接口类型,然后调用接口方法。
    // Fragment
    class MyFragment : Fragment() {
        interface OnMessageListener {
            fun onMessageFromFragment(message: String)
        }
    
        private var listener: OnMessageListener? = null
    
        override fun onAttach(context: Context) {
            super.onAttach(context)
            if (context is OnMessageListener) {
                listener = context
            } else {
                throw RuntimeException("$context must implement OnMessageListener")
            }
        }
    
        // ... 某个事件触发时
        fun sendMessage() {
            listener?.onMessageFromFragment("Hello from Fragment!")
        }
    
        override fun onDetach() {
            super.onDetach()
            listener = null
        }
    }
    
    // Activity
    class MainActivity : AppCompatActivity(), MyFragment.OnMessageListener {
        override fun onMessageFromFragment(message: String) {
            Log.d("MainActivity", "Received message: $message")
        }
        // ...
    }
    
    • ViewModel (推荐,尤其是 Fragment 间通信): 使用共享的 ViewModel 可以非常方便地在 Fragment 和 Activity 之间共享数据和通信,尤其是在导航组件的场景下。
  2. Activity 到 Fragment:

    • 调用 Fragment 公开方法: Activity 可以获取 Fragment 实例并直接调用其公共方法。
    // Activity
    fun sendMessageToFragment(message: String) {
        val myFragment = supportFragmentManager.findFragmentById(R.id.fragment_container) as? MyFragment
        myFragment?.updateText(message)
    }
    
    // Fragment
    class MyFragment : Fragment() {
        fun updateText(message: String) {
            // 更新 TextView
        }
    }
    
    • 通过 Bundle 传递参数: 在创建 Fragment 实例时,通过 setArguments(Bundle) 方法传递参数。
    // Activity
    val args = Bundle().apply {
        putString("key_message", "Data from Activity")
    }
    val myFragment = MyFragment().apply {
        arguments = args
    }
    
    // Fragment
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val message = arguments?.getString("key_message")
        // 使用 message
    }
    
  3. Fragment 到 Fragment:

    • 通过宿主 Activity 中转(旧版,不推荐): 一个 Fragment 通知 Activity,Activity 再通知另一个 Fragment。
    • 共享 ViewModel (推荐): 多个 Fragment 可以观察同一个 ViewModel 中的 LiveData,实现数据共享和通信。
    • Parent-to-Child FragmentManager (如果存在嵌套 Fragment): 可以通过 getParentFragmentManager()getChildFragmentManager() 获取对应的 FragmentManager
    • Navigation Component (推荐): 使用 Android Navigation 组件是处理 Fragment 之间导航和参数传递的现代化且强大的方式。

FragmentTransaction 和回退栈

当使用 FragmentManager 动态管理 Fragment 时,FragmentTransaction 是执行操作(如添加、移除、替换)的批处理API。

  • add(containerId, fragment): 将一个 Fragment 添加到容器中。
  • remove(fragment): 移除一个 Fragment。
  • replace(containerId, fragment): 移除容器中现有 Fragment,然后添加新的 Fragment。
  • hide(fragment) / show(fragment): 隐藏或显示一个 Fragment,但不会销毁其 View。
  • addToBackStack(name): 将当前 FragmentTransaction 添加到 Activity 的回退栈中。当用户按返回键时,会依次弹出栈中的 Fragment 事务,回退到之前的 Fragment 状态。
  • commit(): 提交事务。这是异步操作。
  • commitNow(): 提交事务。这是同步操作,但可能会阻塞 UI 线程,除非确定操作很快。通常不推荐。
  • commitAllowingStateLoss(): 提交事务,即使 Activity 状态已保存,允许状态丢失。一般不推荐,除非你清楚这样做的后果。

最佳实践与注意事项

  1. 避免 Fragment 嵌套过多: 复杂的 Fragment 嵌套会导致生命周期管理变得困难,并可能引入性能问题。
  2. 使用 setArguments() 传递参数: 避免在 Fragment 构造函数中传递参数,因为系统可能会在屏幕旋转等情况下重新创建 Fragment 而不调用自定义构造函数。
  3. Fragment 应该尽可能独立和可复用: 它们不应该直接依赖于特定的 Activity 类型,而是通过接口或 ViewModel 进行通信。
  4. 处理配置变更: Fragment 在 Activity 重建时也会重建。确保在 onSaveInstanceState()onCreate()/onCreateView() 中正确保存和恢复 Fragment 的状态。
  5. 内存泄漏: 注意在 onDestroyView()onDestroy() 中释放不再需要的引用(尤其是对 View 的引用),以避免内存泄漏。例如,清理在 onCreateView() 中创建的监听器。
  6. getChildFragmentManager() vs getFragmentManager() / getParentFragmentManager()
    • getParentFragmentManager() (原 getFragmentManager()):用于获取管理当前 Fragment 的 FragmentManager
    • getChildFragmentManager():用于获取管理当前 Fragment 内部嵌套 Fragment 的 FragmentManager
    • 在使用 FragmentContainerViewsupportFragmentManager.beginTransaction() 动态添加 Fragment 时,请确保使用正确的 FragmentManager
  7. Navigation Component: 对于复杂的导航和 Fragment 间的通信,强烈推荐使用 Android Jetpack 的 Navigation Component。它简化了 Fragment 的管理、深层链接和安全参数传递。
  8. View Binding 或 Data Binding: 在 Fragment 中使用 View Binding 或 Data Binding 可以更安全、高效地访问 View 组件,避免 findViewById 带来的空指针异常。

常见用例

  • 标签页(Tabbed Layouts): 每个标签页内容可以是一个 Fragment。
  • 滑动视图(Swipe Views / ViewPager2): ViewPager2 经常与 FragmentStateAdapter 结合使用,每个页面都是一个 Fragment。
  • 大屏幕设备布局: 例如,在平板上,一个 Fragment 显示列表,另一个 Fragment 显示详情。
  • 底部导航栏(Bottom Navigation): 每个导航项对应一个 Fragment。
  • 向导流(Wizard Flows): 多个 Fragment 按顺序引导用户完成任务。

【Android基础】Activity组件进阶

【Android基础】Activity组件进阶

本文介绍了Activity组件的进阶知识,包括Activity的生命周期、Activity的启动模式、Activity的通信方式等。

作为一名 Android 开发者,Activity 绝对是你最常用、也是最重要的组件。它是用户界面的单一入口点,承载着应用与用户交互的各种操作。你可以把它想象成应用中的一个“屏幕”或“页面”。

  • Activity 提供一个绑定好的窗口,你可以在其中使用各种View和ViewGroup来绘制 UI 界面(如按钮、文本框、图片等),供用户进行交互。
  • Activity 拥有一套定义好的生命周期回调方法,应用开发者根据这些回调来配置特定的任务,比如在创建的时候配置View的交互行为,数据初始化,销毁时释放资源。
  • 每个 Activity 实例都与一个任务(task)相关联。当用户启动应用时,系统会为它创建一个任务,并在这个任务中管理 Activity 的堆栈。
  • Activity 也可以启动其他 Activity(包括自己应用内或第三方应用的 Activity),并通过 IntentBundle 传递数据。

生命周期

理解 Activity 的生命周期是 Android 开发的基石。当你用户在应用中导航、接电话、切换应用等操作时,Activity 的状态会发生变化,系统会调用相应的回调方法。

以下是 Activity 生命周期中的几个核心方法:

  • onCreate():
    • 何时调用: Activity 首次创建时调用。
    • 作用: 进行 Activity 的初始化工作,如设置布局(setContentView())、初始化视图组件、绑定数据、恢复 savedInstanceState 中的数据等。这是你放置大部分一次性设置代码的地方。
  • onStart():
    • 何时调用: Activity 可见时调用,无论是因为首次创建还是从后台回到前台。
    • 作用: Activity 即将对用户可见,但尚未获得用户焦点。
  • onResume():
    • 何时调用: Activity 获得用户焦点并可与用户交互时调用。
    • 作用: 在这里启动动画、访问设备相机或传感器等独占性资源。任何需要 Activity 处于前台才能进行的轻量级操作都应放在这里。
  • onPause():
    • 何时调用: Activity 失去焦点时调用,例如用户点击返回键,或启动了另一个 Activity 但当前 Activity 仍然部分可见(如弹出一个对话框),或屏幕关闭。
    • 作用: 在这里暂停动画、释放独占性资源(如相机预览),并保存任何需要持久化的小量数据(但不要在这里执行耗时操作,因为下一个 Activity 必须等待当前 Activity 的 onPause() 执行完毕才能 onResume())。
  • onStop():
    • 何时调用: Activity 不再可见时调用,例如用户切换到另一个应用、按下 Home 键、或启动了一个完全覆盖当前 Activity 的新 Activity。
    • 作用: 释放那些在 Activity 不可见时不再需要的资源。重量级 CPU 操作,例如向数据库写入数据,应该在这里完成。
  • onDestroy():
    • 何时调用: Activity 被系统销毁时调用。这可能是以下原因之一:
      • 用户通过按下返回键完全退出 Activity。
      • 系统为了回收资源而销毁 Activity(例如,当内存不足时)。
      • 配置变更(如屏幕旋转、主题切换)导致 Activity 重新创建。
    • 作用: 释放所有在 onCreate() 中创建的资源,如解绑广播接收器、关闭数据库游标、停止后台线程等。

可以用下面这个图来概括:

Activity 状态和数据保存

当 Activity 被销毁后又重建时(例如屏幕旋转,主题切换),你可能需要恢复之前的用户界面状态或数据。Activity提供了以下两个方法,分别用于在Activity被销毁前和重建后保存和恢复数据:

  • onSaveInstanceState(Bundle outState):
    • Activity 即将被销毁,但未来可能会被重新创建时调用(例如屏幕旋转、内存不足导致系统回收)。
    • 你可以将少量瞬态数据(如 UI 状态、滚动位置等)保存到 Bundle 中。这个 Bundle 会在 Activity 重新创建时通过 onCreate() 方法传递回来。
  • onRestoreInstanceState(Bundle savedInstanceState):
    • onStart() 之后,并且仅当 Activity 之前因系统原因被销毁并重新创建时调用。
    • 在这里恢复 onSaveInstanceState() 中保存的数据。通常,也可以在 onCreate() 中通过 savedInstanceState 参数来恢复这些数据。

注意: 对于大量数据或需要长期保存的数据,不应依赖 onSaveInstanceState()。而应该使用 Room 数据库、SharedPreferences(建议迁移到DataStore)或 ViewModel 来持久化保存数据。

启动 Activity 和数据传递

你可以使用 Intent 来启动其他 Activity。

// 启动一个显式 Activity (明确指定要启动的 Activity 类)
val intent = Intent(this, SecondActivity::class.java)
startActivity(intent)

// 启动 Activity 并传递数据
val dataIntent = Intent(this, DetailActivity::class.java).apply {
    putExtra("item_id", 123)
    putExtra("item_name", "Awesome Product")
}
startActivity(dataIntent)

// 在 DetailActivity 中获取 Intent 的数据
// override fun onCreate(savedInstanceState: Bundle?) {
//     super.onCreate(savedInstanceState)
//     val itemId = intent.getIntExtra("item_id", -1)
//     val itemName = intent.getStringExtra("item_name")
// }

// 4. 启动 Activity 并获取返回结果 (旧方法,现在推荐 Activity Result API)
// override fun onCreate(...) {
//     val startForResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
//         if (result.resultCode == Activity.RESULT_OK) {
//             val data: Intent? = result.data
//             val message = data?.getStringExtra("return_message")
//             // 处理返回的数据
//         }
//     }
//
//     // 在某个点击事件中启动
//     myButton.setOnClickListener {
//         val intent = Intent(this, ResultActivity::class.java)
//         startForResult.launch(intent)
//     }
// }

如果一个Activity已经启动到前台了,但是其他组件仍然调用了startActivity,这个时候一般会回调 onNewIntent() 方法,可以在这个回调里获取数据。

Activity 任务栈 (Task Stack)

Android 系统通过 任务(Task) 来管理 Activity 的组织结构。一个任务是用户执行某项工作时与之交互的 Activity 的集合。这些 Activity 被组织在一个“后退栈”(Back Stack)中,以 堆栈(LIFO,后进先出) 的形式排列。

  • 当用户启动一个新 Activity 时,它会被推送到当前任务栈的顶部,并成为焦点。
  • 当用户按下返回键时,栈顶的 Activity 会被弹出并销毁,前一个 Activity 恢复到顶部。
  • 当栈中最后一个 Activity 被弹出时,这个task任务就不再存在。

你可以在 AndroidManifest.xml 中使用 android:launchMode 来调整 Activity 的启动模式,从而影响其在任务栈中的行为,有以下四种启动模式:

  • standard (默认): 每次启动都会创建新的实例。
  • singleTop 也叫栈顶复用,如果目标 Activity 已经在栈顶,则不会创建新实例,而是调用其 onNewIntent() 方法。
  • singleTask 也叫栈内复用,确保一个任务栈中只有一个该 Activity 的实例。如果实例已存在于任何位置,则将其移动到栈顶并清理其上方的所有 Activity。
  • singleInstance 类似于 singleTask,单例模式,但它会创建一个全新的任务来包含这个 Activity,并且这个任务中只能有这一个 Activity。

Activity 和 Fragment 的关系

一般稍微大型一点的项目,都会使用 Activity 和 Fragment 一起工作。理解它们的区别和联系至关重要:

  • Activity 是骨架: Activity 提供应用窗口和基本框架,管理整个屏幕的生命周期。
  • Fragment 是模块: Fragment 是 Activity 的一部分,有自己的生命周期和布局,但必须依附于 Activity。它用于构建模块化、可复用的 UI 片段。
  • 分工合作: Activity 负责协调不同 Fragment 之间的交互、处理系统事件,而 Fragment 负责管理其内部的 UI 逻辑和数据。

跳转和创建流程

可以参考应用冷启动流程的文章,详细介绍了进程初始化和Activity内部窗口,DecorView和contentView的绑定流程。

APP冷启动流程解析

【Android基础】多屏开发

【Android基础】多屏开发

本文介绍了Android多屏开发的两种方式

车载Android开发和手机端的一个重大区别之一,就是车载Android设备通常拥有多个屏幕,比如一个主屏幕和一个副屏幕。在手机端通常只需要一个屏幕,但是在车载Android开发中,我们很多时候需要同时处理多个屏幕。

例如现在相当一部分的新能源车拥有主副驾两块大屏幕,主驾显示的界面为导航和车辆状态等,副驾屏幕用来显示一些娱乐app的流媒体等。甚至很多车企,还会有吸顶屏幕给后排乘客使用。

本文将介绍Android多屏开发主流的两种实现方式:Presentation和Activity。以下两种方式默认层级场景下,无系统权限的app也可以使用。

多屏设备的获取

首先,我们需要获取到多屏设备的信息,包括屏幕的数量、屏幕的尺寸、屏幕的方向等。

在Android中,我们可以通过DisplayManager来获取到多屏设备的信息。

fun getConnectedScreenIds(context: Context): List<Int> {
    val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
    val displays = displayManager.displays
    val screenIds = mutableListOf<Int>()
    for (display in displays) {
        screenIds.add(display.displayId)
        display.name
        infoLog("displayId = ${display.displayId}, name = ${display.name}")
    }
    return screenIds
}

其中,displayId是屏幕的唯一标识符,displayName是屏幕的名称。

其中display.getSize方法已经废弃,改为采用windowmanager中获取密度的方法来获取尺寸。

每次开关机之后,,displayId有可能不是固定的,主要看系统厂商是否对多屏的id进行了重置。

Presentation

Presentation是Android提供的一种用于显示UI的类,它可以在一个单独的窗口中显示UI。

Presentation继承自Dialog类,因此它也可以设置窗口的属性,比如窗口的大小、窗口的位置等。

设置Presentation时,需要传入一个Context,一个Display对象,以确定Presentation的显示位置。然后在onCreate方法中设置UI即可。

class MyPresentation(context: Context, display: Display) : Presentation(context, display) {

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

创建实例,调用show方法。同时,我们也可以精细地定义其显示层级,显示大小,以及是否可以点击外部取消。


val displayManager =
    this@MainActivity.getSystemService(DISPLAY_SERVICE) as DisplayManager
val display = displayManager.getDisplay(5)
val presentation = MyPresentation(this@MainActivity, display)
presentation.setCancelable(false)
presentation.show()

Activity

第二种方案可以直接使用Activity来实现多屏开发。

使用到了ActivityOptions类,官方定义为,其是一个用于构建一个选项Bundle的辅助类,该Bundle可与Context.startActivity(Intent, Bundle)及相关方法一起使用。

我们可以利用它来定义转场动画等关于显示的很多参数。这里用到了其指定displayid来显示的功能。

fun startPassengerActivity() {
    val intent = Intent().apply {
        setComponent(ComponentName("com.stephen.redfindemo", "com.stephen.redfindemo.PassengerActivity"))
        setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
    }
    val displayId = 5;
    val options = ActivityOptions.makeBasic();
    options.setLaunchDisplayId(displayId);
    try {
        this.startActivity(intent, options.toBundle());
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

【Android基础】应用层AIDL接口的实现

【Android基础】应用层AIDL接口的实现

本文介绍了应用层定义AIDL接口并使用其通信的简单手法。

通常包括以下步骤

定义AIDL接口文件

首先,通信的服务端和客户端,需要创建一个AIDL文件来定义接口。AIDL文件类似于Java接口文件,但它使用AIDL语法。例如,你可以创建一个名为IAirConditionerService.aidl的文件,并在其中定义你的接口方法。客户端和服务端,需要在同一个包名的文件夹里面创建。

interface IAIRConditionerService {
    void setTemperature(int temperature);
    int getTemperature();
    // 其他方法...
}

编译AIDL文件

在Android Studio中,点击build之后,AIDL文件会自动编译。编译后,会生成一个Java接口文件,位于app/build/generated/source/aidl/debug/目录下(假设你的项目是在debug模式下编译的)。这个生成的Java接口文件包含了与AIDL文件中定义的接口相对应的方法。

实现服务端的Service类

接下来,你需要创建一个Service类来实现AIDL接口。这个Service类将处理来自客户端的请求。在Service类中,你需要实现AIDL接口中定义的所有方法。

import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import android.os.RemoteException;

public class AirConditionerService extends Service {
    private final IAIRConditionerService.Stub binder = new IAIRConditionerService.Stub() {
        @Override
        public void setTemperature(int temperature) throws RemoteException {
            // 实现设置温度的逻辑
        }

        @Override
        public int getTemperature() throws RemoteException {
            // 实现获取温度的逻辑
            return 0;
        }
        // 其他方法的实现...
    };

    @Override
    public IBinder onBind(Intent intent) {
        return binder;
    }
}

然后在AndroidManifest.xml中注册Service:你需要在AndroidManifest.xml文件中注册你的Service,以便系统能够找到并启动它。

客户端调用

在客户端应用中,你可以通过绑定到Service来调用AIDL接口中的方法。首先,你需要创建一个ServiceConnection对象,并在其中实现onServiceConnected和onServiceDisconnected方法。然后,你可以使用bindService方法来绑定到Service。

// 客户端Activity或Service
public class AirConditionerClientActivity extends AppCompatActivity {
    private IAIRConditionerService airConditionerService;
    private ServiceConnection serviceConnection = new ServiceConnection() {
        @Override
        public void onServiceConnected(ComponentName name, IBinder service) {
            airConditionerService = IAIRConditionerService.Stub.asInterface(service);
            try {
                int temperature = airConditionerService.getTemperature();
                // 处理返回的温度值
            } catch (RemoteException e) {
                e.printStackTrace();
            }
        }

        @Override
        public void onServiceDisconnected(ComponentName name) {
            airConditionerService = null;
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_air_conditioner_client);

        Intent intent = new Intent(this, AirConditionerService.class);
        bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        unbindService(serviceConnection);
    }
}

一般手机平台上,两个三方app之间要建立这个连接,客户端需要在AndroidManifest里面加一条权限声明:

  <uses-permission
        android:name="android.permission.QUERY_ALL_PACKAGES"
        tools:ignore="QueryAllPackagesPermission" />

实际的例子

这是我的第一个Android项目,模仿网易云音乐开发了一个约等于静态界面的app,体验了AIDL接口的跨进程通信。

  • 客户端:“网抑云”app
  • 服务端:一个扫描sdcard内音乐文件的服务进程

直接先看项目文件:

Server

server

首先就是aidl文件夹,里面定义了通信的接口声明aidl和需要传输的实体类Music的aidl.

AIDLtest.aidl

package com.example.server;
import com.example.server.Music;

interface AIDLtest {

    int add(int num1, int num2);
    List<Music> getmusiclist();
    List<Music> addmusic();
}

我们扫描到音频文件之后,需要将信息通过Music类包装传过去。

Music.aidl

// Music.aidl
package com.example.server;

parcelable Music;

Mudic.java

package com.example.server;

import android.os.Parcel;
import android.os.Parcelable;

public class Music implements Parcelable {
    public String name;
    public String singer;

    public Music() {
    }

    protected Music(Parcel in) {
        name = in.readString();
        singer = in.readString();
    }

    public static final Creator<Music> CREATOR = new Creator<Music>() {
        @Override
        public Music createFromParcel(Parcel in) {
            return new Music(in);
        }

        @Override
        public Music[] newArray(int size) {
            return new Music[size];
        }
    };

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getSinger() {
        return singer;
    }

    public void setSinger(String singer) {
        this.singer = singer;
    }

    @Override
    public String toString() {
        return "Music{" +
                "name='" + name + '\'' +
                ", singer='" + singer + '\'' +
                '}';
    }

    @Override
    public int describeContents() {
        return 0;
    }

    @Override
    public void writeToParcel(Parcel dest, int flags) {
        dest.writeString(name);
        dest.writeString(singer);
    }
}

java类需要实现Parcelable接口才可以顺利传输,写法是比较固定的。

Client

client

客户端建立aidl文件夹时,各个类所属的包名一定要和服务端一致,可以看到图片中aidl文件夹是直接从服务端移植过来的。

需要注意的是,实体类Music,java文件的定义,也需要和服务端的包名一致,这里是单独建了一个包来放置它。

至于后面的调用流程就和上面一节提到的是一致的了。

车载Android上普遍的方式

上面的是手机端比较基础的通信集成方式,在车载Android领域,需要跨进程通信时,往往会采取对客户端最友好的方式来实现。

我们会在服务端的代码仓库里面直接新建一个libary的模组。

或者单独做一个仓库,来放置不同app的对外接口文件。

在这些地方,由服务端,也就是接口提供方的开发工程师,来完成客户端的Service连接逻辑,并把方法包装成aar提供给需求方。

然后客户端需求方的项目去集成这个aar,自己决定什么时机调用。

Pagination