Dagger的概念以及module的引入

依赖注入(Dependency injection)

依赖注入是软件工程中的一种设计模式,也是实现控制反转的其中一种技术。这种模式能让一个对象接收它所依赖的其他对象。依赖是指接收方所需的对象。注入是指将依赖传递给接收方的过程。在注入之后,接收方才会调用该依赖。此模式确保了任何想要使用给定服务(依赖)的对象(接收方像是client)不需要知道如何创建这些服务, 也不知道外部代码(注入器)是如何提供接收方所需的服务。

编程语言层次下,“接收方”为对象和 class,“依赖”为变量。在提供服务的角度下,“接收方”为客户端,“依赖”为服务。

该设计的目的是为了分离关注点,分离接收方和依赖,从而提供松耦合以及代码重用性。

传统编程方式,客户对象自己创建一个服务实例并使用它。这带来的缺点和问题是:

  • 如果使用不同类型的服务对象,就需要修改、重新编译客户类。
  • 客户类需要通过配置来适配服务类及服务类的依赖。如果程序有多个类都使用同一个服务类,这些配置就会变得复杂并分散在程序各处。
  • 难以单元测试。本来需要使用服务类的 mock 或 stub,在这种方式下不太可行。

依赖注入可以解决上述问题:

  • 使用接口或抽象基类,来抽象化依赖实现。
  • 依赖在一个服务容器中注册。客户类构造函数被注入服务实例。框架负责创建依赖实例并在没有用户时销毁它。

以下图示就是一个典型的DI模式

我们的Dagger就是一个用来实现DI Pattern的框架。

解决的问题

可以参考这个slides

概念

Dagger实现依赖注入的原理是在编译阶段通过程序中的注解构建一个依赖关系图,然后根据依赖关系图来生成对象工程,并在需要的地方进行注入。而我们要做的是在合适的地方进行注解,从而帮助Dagger生成这张依赖关系图。

Inject

在这张依赖关系图中,我们首先需要声明哪些对象需要注入,以及这些对象如何生成。首先声明哪些对象需要注入,只要使用Inject关键字对该对象进行注解即可

    // 在App类中声明一个成员变量tea,并使用Inject注解告诉Dagger这个对象需要注入
    @Inject
    lateinit var tea: Tea

然后要告诉Dagger框架如何初始化该对象。也只要在类的构造函数中添加Inject注解

class BlackTea @Inject constructor(): Tea() {
    ...
}

这就是Inject注解的所以使用方法。接下来就是要将两者联系起来,就靠modulecomponent来实现了

module

module也被翻译成模块,它提供了注入对象的。Dagger通过它来获知如何构建一个依赖对象。有两种方式

  • Provides
  • Binds
    @Provides
    fun provideTea(): Tea {
        return Tea.make(Tea.Type.BLACK);
    }

provides通过字面意思就是提供者,提供了某个对象如何构建。在这个函数里,我们可以自由的选择如何生成这个对象。函数返回类型告诉Dagger这个函数提供何种返回对象,可以是一个具体类,也可以是一个接口

@Binds
abstract fun bindTea(BlackTea blackTea): Tea;

Binds的意思是将一个父类(函数返回对象)和一个子类(函数参数)进行绑定,当需要父类类型的对象时就使用子类的默认构造函数(如果有多个构造函数用@Inject注解指定)生成具体对象。Binds类型函数必须是一个抽象方法或者试接口类里的一个方法

可以看出来,Provides类型的函数可定制程度比较高,而Binds类型的函数比较简洁。

module根据情况可以是一个普通类,也可以是一个抽象类,甚至可以是一个接口类。下面是一个典型的module类

@Module
class BeverageModule {
    @Provides
    fun provideTea(): Tea {
        return Tea.make(Tea.Type.BLACK);
    }
}

component

component或者叫做容器。容器里包含了module以及依赖的对象,并提供了module里的对象的生命周期。在注解中添加需要 包含的module列表,这样子的话,这个module里提供的对象的声明周期就归这个Component管理了。

@Component(modules = [BeverageModule::class])
interface AppComponent {
    fun inject(app: App)
    fun makeTea(): Tea

关于生命周期

这里的生命周期怎么理解呢?一般使用Dagger的话会在某个类中调用Dagger框架中的模板方法生成Component对象。需要使用Component包含的 module所提供的对象的类就需要通过持有Component对象的类获取Component对象,然后显示告知Dagger框架此类需要使用Component所包含的module 提供的对象来给类中用Inject注解的对象赋值。类结构大概如下所示

从这个结构中我们就可以大致了解,App类持有Component对象,所以这里的Component对象的生命周期也就由App类决定了。

在Android应用中,这个App类对应的就是Application类,而Business类就是各个Activity类。Activity类通过getApplication并访问其中的 Component对象来完成注入。

结尾

有了这三个基本的工具后,我们就能帮助Dagger框架搭建一个完整的依赖关系图了。整个结构如下所示

一个简单的Dagger注入实例

这篇文章我们会用12行代码来展示最简单的Dagger的使用。代码使用kotlin,用gradle编译,配置代码使用groovy。

首先我们使用gradle init生成最基础的基于kotlin的Application工程。使用的gradle版本是7.5.1, 也可以直接fork最后的sample code。(这个sample code使用的是gradlew,就不用纠结全局的gradle版本了)

为了避免不必要的编译错误,务必保持版本一直或者相近,否则会造成诸如包找不到等错误

然后我们引入kotlin-kapt插件

id "org.jetbrains.kotlin.kapt" version "1.7.21"

因为Dagger是通过在编译期帮你生成模板代码来完成注入的,所以需要kapt插件

接着添加依赖

implementation 'com.google.dagger:dagger:2.44.2'  
kapt 'com.google.dagger:dagger-compiler:2.44.2'

最后添加功能代码。

class App {  
    init {  
        DaggerAppComponent.create().inject(this)  
    }  
      
    // 在App类中声明一个成员变量info,并使用Inject注解告诉Dagger这个对象需要注入  
    @Inject lateinit var info : Info  
    val greeting: String  
        get() {  
            return "Hello World!"  
        }  
}  
  
/**  
 * 容器类,用于通知Dagger需要注入的对象  
 */  
@Component  
interface AppComponent {  
    fun inject(app: App)  
}  
  
/**  
 * 注入的对象类  
 */  
class Info @Inject constructor() {  
    val greeting = "hello dagger"  
}  
  
fun main() {  
    println(App().greeting)  
    // 测试注入是否成功  
    println(App().info.greeting)  
}

我们要完成的功能很简单,创建一个Info实例并赋值给App类中的info变量。上述代码分成四个部分

  1. 创建Info类,并使用Inject注解constructor函数,告知Dagger这个类的实例使用此构造函数创建
  2. 在App类中创建Info成员变量,并使用Inject注解,告知Dagger这个类的这个变量需要注入
  3. 创建AppCompoent容器,用于开发人员通知Dagger要注入哪个对象。
  4. 调用App类中的info属性,测试注入是否成功

第1,2,4部分都比较好理解,第3部分是因为Dagger只知道将Info对象注入到App类,但是是哪个App类的实例Dagger并不知道,所以需要开发人员主动告知。需要在类的初始化时候就完成这个动作。

    init {  
        DaggerAppComponent.create().inject(this)  
    } 

这里的inject函数的函数名可以是任意的,用inject命名只是遵循通用的命名法。DaggerAppComponent是Dagger框架为AppComponent接口自动生成的实现类,完成了Info的注入。具体实现可以看build/generated/source/kapt/main/example/dagger/DaggerAppComponent.java文件里代码实现

到这里一个简单的Dagger演示代码完成。完整的代码在这里

用vscode+cmake开发android ndk开发环境

vscode 现在对于android ndk的开发支持已经蛮完善了。这里介绍一下如何在vscode使用cmake开发android ndk

插件安装

目标

我们的目标是可以根据不同的ABIbuildType构建出不同的DSO(dynamic shared object, 即so文件)。

在官方文档里,已经介绍了如何用命令行构建不同ABI和buildType的DSO,这里针对Cmake Tools将命令行中的参数填入配置项。

配置文件

为了能够让vscode能够调用cmake生成构建文件并调用ninja进行构建,我们需要三个配置文件

  • variants
  • cmake-tool-kits
  • settings

variants

variants的相关配置可以放到cmake-variants.yaml或者cmake-variants.json,这两者只是格式不一样,效果和配置规则是一样的。这里我们采用yaml的文件格式。 这个文件可以放到工程根目录或者.vscode/目录里。

下面是一个配置的例子

buildType:
  default: debug
  description: My option
  choices:
    debug:
      short: Debug
      long: Build With debugging informationg
      buildType: Debug

    release:
      short: Release
      long: Optimize the resulting binaryies
      buildType: Release

abi:
  default: armeabi-v7a
  description: abi
  choices:
    armeabi-v7a:
      short: armeabi-v7a
      long: General for abi of armeabi-v7a
      settings:
        ANDROID_ABI: armeabi-v7a
    arm64-v8a:
      short: arm64-v8a
      long: General for abi of arm64-v8a
      settings:
        ANDROID_ABI: arm64-v8a

这里定义了两个选择项buildTypeabi(名字根据自己爱好变更)。buildType定义了构建类型(debug/release)相关的配置参数(这里只是加了一个buildType —— 这个buildType是choices选项里的buildType,是variants配置里定义好的一个属性,并能更改 —— 用于后续构建参数),定义了abi类型 (注意这里的settings, 我们添加了ANDROID_ABI这个字段,这里的设置将作为-D{SETTING_KEY}={SETTING_VALUE)传给CMake)

如果项目组都用vscode,建议将这个配置文件添加到版本管理中,方便项目组其他人员引入

cmake-tool-kits.json

打开命令面板,输入CMake: Edit User-Local CMake Kits cmake-tool-kits.json配置文件是全局的。这个配置文件配置了构建用的工具链和通用设置。

  {
    "name": "Clang android",
    "compilers": {
      "C": "/Users/mk/Library/Android/sdk/ndk/21.1.6352462/toolchains/llvm/prebuilt/darwin-x86_64/bin/clang",
      "CXX": "/Users/mk/Library/Android/sdk/ndk/21.1.6352462/toolchains/llvm/prebuilt/darwin-x86_64/bin/clang++"
    },
    "environmentVariables": {
      "ANDROID_NDK": "/Users/mk/Library/Android/sdk/ndk/21.1.6352462"
    },
    "toolchainFile": "${env:ANDROID_NDK}/build/cmake/android.toolchain.cmake",
    "cmakeSettings":{
      "ANDROID_NATIVE_API_LEVEL": 23,
      "ANDROID_TOOLCHAIN": "clang",
      "ANDROID_NDK":"${env:ANDROID_NDK}",
      "CMAKE_LIBRARY_OUTPUT_DIRECTORY":"output"
    }
  }

需要在这里设置compilers必须设置,默认的几个都是用的系统clang或者gcc,构建会失败。然后其余的设置都是通用的,所以也都设置在这里的。

settings.json

打开命令面板,输入settings,选择Perferences: Open User Settings

这个就是普通的vscode配置文件,这里配置的是跟项目相关的,包括cmake构建结果输出的位置是否在打开项目的时候是否立即进行构建

{
    "cmake.configureOnOpen": true,
    "cmake.buildDirectory": "${workspaceFolder}/build/${variant:buildType}/${variant:abi}"
}

Additional

如果发现android相关的头文件引用和相关符号无法识别,可以在.vscode文件夹中增加c_cpp_properties.json的配置文件,并增加以下配置。 加完配置后就可以在右下角的配置里选择name对应的配置项(我们这里是Android)

这个配置文件是给C/C++插件用的,所以需要安装C/C++插件才会起作用

{
    "configurations": [
        {
            "name": "Android",
            "includePath": [
                 "${workspaceFolder}",
                 "/Users/mk/Library/Android/sdk/ndk/21.3.6528147/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/include/aarch64-linux-android/**"
            ],
            "defines": [],
            "compilerPath": "${env:ANDROID_NDK}/toolchains/llvm/prebuilt/darwin-x86_64/bin/clang++",
            "cStandard": "c11",
            "cppStandard": "c++17",
            "intelliSenseMode": "clang-x64",
            "configurationProvider": "ms-vscode.cmake-tools"
        }
    ],
    "version": 4
}

x86_64和arm64的inline-assembly的hello world

这这篇文章里提供了两种ABI下的hello world如何在inline assembly下实现。从这些代码里我们可以学到几个技术点

  • inline assembly的格式
  • 不同ABI的寄存器使用(尤其是arm64)
  • 不同ABI的系统调用

这个篇的基础上,我们可以自行扩展测试其他的汇编特性。比如汇编和C的互相调用, label的使用

基础

inline assmble的基础语法

asm asm-qualifiers ( AssemblerTemplate
                 : OutputOperands
                 [ : InputOperands
                 [ : Clobbers ] ])

asm asm-qualifiers ( AssemblerTemplate
                      : OutputOperands
                      : InputOperands
                      : Clobbers
                      : GotoLabels)

asm 关键字表示这是一个inline assembly

这里不详述了,官方文档写的还是很详细的

x86_64

/* hello_x86_64.c */
#include <unistd.h>
#include <asm/unistd.h>
ssize_t my_write(int fd, const void *buf, size_t size);
void my_exit();
ssize_t my_write(int fd, const void *buf, size_t size)
{
    ssize_t ret;
    asm volatile
    (
        "syscall"
        : "=a" (ret)
        // RAX             RDI      RSI       RDX
        : "A"(__NR_write), "D"(fd), "S"(buf), "d"(size)
        : "rcx", "r11", "memory"
    );
    return ret;
}

void my_exit() {
   /* __asm__ ("movq $60, %rax\n\t" // the exit syscall number on Linux*/
             /*"movq $2,  %rdi\n\t" // this program returns 2*/
             /*"syscall");*/
    asm ("syscall" :: "A"(__NR_exit), "D"(100));
}

int main() {
    my_write(1, "hello world!\n",  13);
    my_exit();
	// 这里没有return也是可以的,因为在my_exit函数里就已经退出程序了; 可以用echo $?查看最终的exit code,
	// 不是main函数的retrun值,而是上面my_exit的退出参数100
	return 0;
}

arm64

arm64的line assembly不支持在input/output里指定特定的寄存器,但是系统调用需要将特定的参数放到特定寄存器中,所以就需要用register关键字将变量和寄存器进行绑定

经过测试,通用寄存器都是从0开始依次使用,所以,fd_local, buf, size_local这些都不是必须的,因为本来他们就是放到x0,x1,x2,刚好符合顺序,这里为了统一并不对读者产生阅读干扰,都是指定了寄存器。

/* hello_arm64.c */
#include <unistd.h>
#include <asm/unistd.h>

int my_write(int fd, const void *buf, size_t size) {
    register long ret asm("x0");
    register long fd_local asm("x0") = fd;
    register const void *buf_local asm("x1") = buf;
    register size_t size_local asm("x2") = size;
    register long call  asm("r8") = __NR_write;
    asm volatile(
            "svc #0" // 系统调用,用arm-develop的说法就是陷入E1层,和x86的原理不太一样
            : "=r" (ret)
            : "r"(call), "r"(fd_local), "r"(buf), "r"(size_local)
            : "memory"
    );
    return ret;
}

int main() {
    my_write(1, "hello world!\n", 13);
    return 0;
}

这段代码要用arm64(aarch64)的toolchain进行编译;可以选用android ndk提供的或者单独安装编译链工具集; 这里采用android ndk

 $ $ANDROID_SDK/ndk/21.3.6528147/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android29-clang hello_arm64.c -o hello_arm64

如果有root的android手机,可以push到手机上运行;或者安装模拟器,linux系统可以安装qumu来运行

qemu-aarch64  hello_arm64

还有一种方法就是用docker安装arm64-v8a的ubuntu,这样就可以在对应的container里构建运行了,比较方便

android 存储发展史

android的存储分为内部存储(internal)和外部存储(external)。内部存储就是我们熟知的/data/data, 这部分 存储仅限于当前应用(用户)访问,其他应用(用户)无法访问。外部存储就是我们熟知的sdcard,也是我们这里要 说的重点。

在早期的设备中,内部存储和外部存储是硬件设备。因为存储器件价格昂贵,所以厂商的内部存储一般都比较小(我的 第一台手机是htc desire Z,它的内部存储才1.5G), 为了存储一些大的文件,比如照片、视频,就需要外部扩展sdcard。 这时候的sdcard就是真的物理设备上的sdcard。

早期外置的sdcard卡一般都是FAT系列的文件系统,比如FAT32。所有的应用都是可以自由访问的,在1.5之后,要访问就需要 申请WRITE_EXTERANL_STORAGE权限。
后面随着存储原器件的价格降低,厂商开始将内部存储划出一个分区,作为外部存储区,并逐渐不再支持扩展的物理sdcard。为了兼容性,这块分区仍然命名为sdcard。这块分区也是我们熟知的/storage/self/primarysdcard文件夹使用symlink,链接到prmiary目录。 在支持物理sdcard的设备上,物理sdcard的叫/storage/self/secondary

Galaxy Nexus 2应该是第一款不支持外置存储的手机,ROM的大小也之后16G。

2.3

在2.3的时候,android引入FUSE,用于Emulation在存储设备上一个文件系统。最主要的功能是在一个在一个有权限控制的文件系统上(比如ext4)上模拟一个权限控制较弱的文件系统(比如FAT),以便应用间分享数据(应用间文件分享是android系统一个重要的功能)

在2.x的时代,要在PC上访问sdcard上的文件,需要将这个分区/设备挂载到PC上,这样会造成应用无法正常访问里面的文件。

3.1

在Android 3.1中,系统引入了 introduction of the Media Transfer Protocol简称MTPMTP提供了一个访问协议,由MediaStorage提供Provider给MTP,在MTP接收到访问指令的时候,向它提供文件列表/文件内容等数据。这样的话,连接到PC的手机就不会再将sdcard挂载到PC上了

4.0

Android 4.0是一个划时代的版本,从这个版本开始,OEM厂商开始大规模的不支持外置sdcard,并将/data/media文件夹”mount”成sdcard,也就是primary external storage.

4.3

Android 4.3开始,init、vold 和 recovery 所使用的各种 fstab 文件在 /fstab. 文件中进行统一 这部分配置项的变更可以查看官方的[存储/设备配置](https://source.android.com/devices/storage/config#file_mappings)说明

4.4~

从Android 4.4开始,系统开始增加应用在扩展存储中的沙盒机制,允许应用在没有授权的情况下读取应用的沙盒文件(在/Android/{data,meida,obb}/{PackageName}目录下)。并且在这个版本,系统引入了存储访问框架,用于有需要的应用可以浏览和打开各种文档、图片及其他文件,而不用管这些文件来自其首选文档存储提供程序中的哪一个。存储访问框架(Storage Access Framework, 简称SAF)在7.0的版本上增加了分区存储(scoped stroage)的特性, android 10后增强了这个特性(以android 10为目标版本的应用将无法访问扩展存储,除非在AndroidManifest.xml中声明了requestLegacyExternalStorage), 在android 11中系统设置会忽略这个整个声明。