Jetpack - Hilt

本文介绍了Android开发中Hilt依赖注入框架。阐述了依赖注入概念及优势,如解决对象初始化逻辑、实现控制反转等。详细说明了添加依赖、配置入口点、注入实例等步骤,还介绍了Hilt组件的生命周期、作用范围,以及在ViewModel中的使用和实际应用场景。

官方文章

参考文章

一、概念

        依赖管理不仅可以很好地解决对象繁琐的初始化逻辑,还可以很好的实施控制反转的编码思想。

        类中使用的某个对象不是在这个类中实例化的(如汽车类内部创建轮子对象),而是通过外部注入(在外部初始化后传入后使用,汽车类不再自己造轮子实现解耦,可以很容易更换不同类型的轮子,或测试时模拟一个轮子而不用修改汽车类),这种实现方式就称为依赖注入 Dependency Injection(简称DI),也叫控制反转 Inversion of Control(简称 IoC,对象的创建交给外部)。

        不只是单纯的传参作用,使得我们的代码更加模块化,每个类都只关注自己的职责,不需要关心其依赖对象的创建和管理,使得代码更容易重用:

  • 对于 MVVM 架构,UI层的 Activity/Fragment 实例不是由我们操心创建的,VM层的 ViewModel 实例 Jetpack 提供了专门的 API 来获取 ,但是 Respository 层的仓库实例创建就成了问题,VM层依赖 Repository 层但不应该负责创建实例,如果设置成单例谁都拥有了对它的依赖关系违背了不能夸层级的规则(如UI跨过VM直接通信Repository),借助依赖注入框架就能很好的解决。(不然UI中要依次创建并传递ApiService→DataResource→Repository→ViewModel)
  • 对于 Android 经常需要访问一些共享资源或服务(如网络请求、数据库访问、ViewModel等),没有 DI 就需要手动创建这些对象导致代码重复和耦合难以单元测试,使用 DI 就可以统一配置然后在需要的地方自动注入。
  • 对于 APP/Moudle 不同模块需要访问一些共享的服务,没有 DI 就需要通过复杂的方式获取这些服务导致代码复杂难管理,使用 DI 可以在 APP 中配置后在 Moudle 中自动注入。
  • 对于单例(如数据库、网络客户端),使用 DI 可以轻松管理,避免了手动管理的复杂性。
构造注入将对象B通过构造传参给classA。有些对象无法通过实例化使用,如Activity。
字段注入将对象C通过函数设置给classA的字段(也叫setter注入、属性注入)。如果类的依赖类型非常多,而且要严格执行顺序(如造车前要造好轮子,造轮子又需要先造好螺丝和轮胎),随着项目越发复杂需要编写很多模板代码耦合度也更高,手动注入就容易出错。
方法注入将对象D传入到classA的方法中,仅在该方法中使用。
工厂注入ClassA调用工厂类生产对象调用和生产不在同一个地方,不利于修改测试。
单例注入ClassA调用单例类获取其持有的对象对象的生命周期难以管理,通常并不需要存在于整个APP生命周期,指定在特定的生命周期又需要添加很多判断。

自动注入

基于反射的解决方案,可以在运行时连接依赖类型过多使用反射方法会影响程序的运行效率,而且反射方法在编译阶段是不会产生错误的,导致只有在程序运行时才可以验证使用方式是否正确。Square开发的Dagger。
静态解决方案,通过注解解决了反射的弊端,还在编译时就自动生成依赖注入的代码不会增加运行耗时。在编译时就可以发现依赖注入使用的问题。谷歌基于Dagger开发出Dagger2和Hilt,Dagger2使用起来复杂繁琐,而Hilt专门面向Android开发提供更简单的实现方式,和其它Jetpack组件能更好的协同工作。

Dagger是“匕首”,Hilt是“把柄”,给你安全的把柄避免用不好锋利的匕首误伤自己。

二、添加依赖

最新版本

2.1 Project.gradle

plugins {
     id 'com.google.dagger.hilt.android' version "2.44" apply false
}

2.2 app.gradle

plugins {
    id 'kotlin-kapt'
    id 'com.google.dagger.hilt.android'
}
dependencies {
    implementation 'com.google.dagger:hilt-android:2.44'
    kapt 'com.google.dagger:hilt-compiler:2.44'
}
// Allow references to generated code
kapt {
    correctErrorTypes true
}

三、初始化 @HiltAndroidApp

必须自定义一个Application,为其添加 @HiltAndroidApp 注解会触发 Hilt 的代码生成,生成的这一 Hilt 组件会附加到 Application 对象的生命周期,作为应用的父组件可为其它组件提供依赖类型。

注解使用位置说明
@HiltAndroidAppApplication初始化Hilt容器,作为全局依赖入口。
@HiltAndroidApp
class APP : Application() {}

四、将依赖项注入到Android类中 @AndroidEntryPoint

使用 @AndroidEntryPoint 对 Android 类添加注解后,就可以向它里面的字段注入依赖了。声明一个延迟初始化(lateinit var)的属性并添加 @Inject 注解。

  • 为某个 Android 类添加注解,则必须为依赖于该类的其它 Android 类添加注解(例如为 FragmentA 添加注解则必须为所有使用该 FragmentA 的 Activity 添加注解)。 
  •  注入的字段不能为 private 会导致编译错误。
注解使用位置(Android类)说明
@AndroidEntryPointActivity仅支持继承自 ComponentActivity 的 Activity(如AppCompatActivity)。
Fragment仅支持继承自 androidx.Fragment 的 Fragment,不支持 android.app.Fragment。
View
Service
BroadcastReceiver
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    //不能为private
    //使用 lateinit 延迟到 Hilt 决定何时如何实例化
    //lateinit属性未手动初始化,依赖注入提供了实例,所以不会报错
    @Inject
    lateinit var logBean: LogBean    //依赖了LogBean类型,LogBean要进行绑定信息
}

五、通过构造提供实例 @Inject 

为目标类型的构造函数添加 @Inject 注解,向 Hilt 告知如何创建目标类型的实例。不像 Android 类只能字段注入,这样就是构造注入。

  • 若构造函数有参数,参数类型也要进行绑定信息(A依赖B,对A依赖自然要能对B进行依赖)
  • private 修饰的构造函数无法注入,使用 @Module 方式。
//无参
class LogBean @Inject constructor() {}
//有参
data class LogBean @Inject constructor(
    val userName: String,    //又依赖了String类型,String也要进行绑定信息
    val time: TimeBean       //又依赖了TimeBean类型,TimeBean也要进行绑定信息
)

六、通过模块提供实例 @Module 

目标类型的构造函数我们无法添加 @Inject 注解时(private修饰的构造、接口类型没有构造、不属于自己的类型如String、必须使用构建器模式创建实例如Retrofit),就需要通过 @Module 手动创建一个模块类,通过 @InstallIn(XXX::class) 将模块装载到指定的 Hilt 组件中(不同的Android类有对应的Hilt组件),并通过函数提供实例 @Binds @Provids,模块便可以为对应的 Android 类提供依赖(对象的创建、注入、销毁)。

注解使用位置说明
@Module告知 Hilt 如何提供该类型的实例。
@InstallIn告知模块将装载到哪个 Hilt 组件(可用于哪些Android 类)。
@Binds函数提供接口实例。必须对抽象函数注解所以类也是抽象的。返回值告知提供哪种接口类型的实例,参数告知该接口的具体实现类型(该类型也需要对构造注释)。
@Provides提供类实例。可以对class注解,若只包含@provides函数定义为object更高效。返回值告知提供哪种类型的实例,参数告知提供的实例还依赖了哪些类型(这些类型也需要对构造注释),函数体告知如何创建实例(每当需要提供实例时都会执行函数体)。
@Singleton

@Provides

@Inject

用于@Provides注解的方法或@Inject注解的构造函数,告诉Hilt提供的依赖是单例的。

6.1 限制在Android中的生效范围 @InstallIn 

模块通过 @InstallIn(XXX::class) 装载到指定的 Hilt 组件中,组件便可以在对应的 Android 类范围中提供依赖(对象的创建、注入、销毁)。例如@InstallIn(ActivityComponent::class),这个模块安装到 Activity 中那其中的 Fragment、View 也可以使用,但是其它地方就不行了。

  • 不会为 Broadcast 生成组件,会直接从 SingletonComponent 注入广播接收器。
  • 没有 ContentProvider 因为 Hilt 从 Applicaiton#onCreate() 中开始的,而 ContentProvider 在这之前就得到执行。
  • ActivityRetainedComponent(推荐使用)不会随着Activity旋转销毁而销毁,而ActivityComponentd每次都会重新生成组件。 
Hilt组件生效Android类范围
SingletonComponentApplication
ActivityRetainedComponentApplication
ViewModelComponentSavedStateHandle
ServiceComponentApplication、Service
ActivityComponentApplication、Activity
ViewComponentApplication、Activity、View
FragmentComponentApplication、Activity、Fragment
ViewWithFragmentComponentApplication、Activity、Fragment、View

6.2 提供单个实例

6.2.1 提供抽象类/接口实例 @Binds

  • 函数参数的类型,即接口的实现类也要进行绑定依赖。
interface IWork {
    fun show()
}
class WorkImpl @Inject constructor(): IWork {
    override fun show() {}
}

@Module
@InstallIn(ActivityComponent::class)
abstract class WorkModule {
    //定义为抽象函数,因为我们不需要实现
    //函数名叫什么无所谓,因为我们不会去调用
    //返回值类型必须是接口类型,表示给 IWork 类型提供实例
    //函数参数接收了什么实现类类型,就提供什么实例
    @Binds
    abstract fun bindIWork(workImpl: WorkImpl): IWork    //又依赖了WorkImpl类型,即实现类也要进行绑定构造
}

6.2.2 提供普通类实例 @Provides 

  • 若函数有参数,参数类型也要进行绑定依赖。
@Module
@InstallIn(SingletonComponent::class)
class RetrofitModule {
    @Singleton
    @Provides
    fun provideRetrofit(okHeepClient: OkHttpClient): Retrofit {    //又依赖了OkHttpClient类型,OkhttpClient也要进行绑定依赖
        return Retrofit.Builder()
            .client(okHeepClient)
            .baseUrl(ApiService.BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }
}

6.3 提供同类型不同实现的多个实例

实际开发中可能需要创建同类型的多个不同实现的对象使用,如 Student("张三")  和 Student("李四")、String("A") 和 String(“B”)。上面的方式只能为目标类型提供相同实现的对象,通过使用限定符来实现区分不同实现。

6.3.1 使用 @Named

只需要对 @Binds 或 @Provids 注解的函数再使用 @Named 注解,通过传入唯一的 tag 来区分,使用时也要加入对应 tag 让 Hilt 注入的时候选择对应的实例。

@Module
@InstallIn(ActivityComponent::class)
object StringModule {
    @Provides
    @Named("One")
    fun providesOneString() = "One"
    @Provides
    @Named("Two")
    fun providesTwoString() = "Two"
}

@AndroidEntryPoint
class DemoFragment : Fragment() {
    @Inject @Named("One") lateinit var oneString: String
    @Inject @Named("Two") lateinit var twoString: String
}

6.3.2 使用自定义注解 @Qualifier

使用 @Named 方式只能硬编码,因为注解的特性不能传入一个静态的String,很容易写错或后期重构容易遗漏。先根据需要的分类定义注解,使用 @Qualifier 声明作用是为相同类型注入不同实例,使用 @Retention 声明注解的作用范围(AnnotationRetention.BINARY表示注解在编辑后会得到保留,但是无法通过反射去访问这个注解,这应该是最合理的一个注解作用范围),用法和 @Named 相似。

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class OneString

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class TwoString

@Module
@InstallIn(ActivityComponent::class)
object StringModule{
    @Provides
    @OneString
    fun providesOneString(): String = "One"
    @Provides
    @TwoString
    fun providesTwoString(): String = "Two"
}

@AndroidEntryPoint
class DemoFragment : Fragment() {
    @Inject @OneString lateinit var oneString: String
    @Inject @TwoString lateinit var twoString: String
}

七、动态传参(辅助注入)

用于结合框架注入的依赖项和运行时参数。Hilt通过注入它知道的内容,(如你的数据库、API客户端等)来帮助你,而你提供只有在运行时才知道的内容(如用户ID、搜索查询或配置数据)。

@AssistedInject放在构造函数上,而不是常规的@Inject,表明该类需要注入参数和运行时参数的混合。
@Assisted标记运行时参数。创建实例时需要动态提供的值。
@AssistedFactory定义一个工厂接口,使用运行时数据创建实例。可以看做是连接 Hilt 和 动态参数 的桥梁。

7.1 使用

7.1.1 简单使用

class UserRepository @AssistedInject constructor(
    private val database: AppDatabase,     //Hilt提供
    @Assisted private val userId: String     //你动态提供
)

@AssistedFactory
interface UserRepositoryFactory {
    fun create(userId: String): UserRepository    //传入动态参数,返回实例
}

@HiltViewModel
class UserProfileViewModel @Inject constructor(
    //注入的是工厂
    private val userRepositoryFactory: UserRepositoryFactory,
    savedStateHandle: SavedStateHandle
) : ViewModel() {
    //通过工厂,使用动态参数创建实例
    val repository: UserRepository = userRepositoryFactory.create(userId)
}

7.1.2 ViewModel 使用

//ViewModel
@HiltViewModel(assistedFactory = ArticleViewModelFactory::class)
class ArticleViewModel @AssistedInject constructor(
    private val articleRepository: ArticleRepository, // 自动注入
    @Assisted private val articleId: String, // 运行时传参
) : ViewModel() {
    val article = articleRepository.getArticle(articleId)
}

//工厂
@AssistedFactory
interface ArticleViewModelFactory {
    fun create(articleId: String): ArticleViewModel
}

//Activity
@AndroidEntryPoint
class ArticleActivity : ComponentActivity() {
    val viewModel: ArticleViewModel by viewModels(
        extrasProducer = {
            defaultViewModelCreationExtras.withCreationCallback<ArticleViewModelFactory> { factory ->
                factory.create(articleId)
            }
        }
    )
}

7.1.3 WorkManager 使用

Workers不需要工厂,会自动处理工厂创建。只需用@HiltWorker注解,并用@Assisted标记Context和WorkerParameters。

@HiltWorker
class DataSyncWorker @AssistedInject constructor(
    @Assisted private val context: Context,
    @Assisted private val params: WorkerParameters,
    private val syncRepository: SyncRepository, // 注入的
    private val notificationHelper: NotificationHelper // 注入的
) : CoroutineWorker(context, params) {
    
    override suspend fun doWork(): Result {
        return try {
            val dataToSync = params.inputData.getString(KEY_SYNC_DATA) ?: ""
            syncRepository.syncData(dataToSync)
            notificationHelper.showSyncCompleteNotification()
            Result.success()
        } catch (e: Exception) {
            if (runAttemptCount < 3) {
                Result.retry()
            } else {
                Result.failure()
            }
        }
    }
    
    companion object {
        const val KEY_SYNC_DATA = "sync_data"
    }
}

@HiltAndroidApp
class MyApplication : Application(), Configuration.Provider {
    
    @Inject
    lateinit var workerFactory: HiltWorkerFactory
    
    override fun getWorkManagerConfiguration(): Configuration {
        return Configuration.Builder()
            .setWorkerFactory(workerFactory)
            .build()
    }
}

val workRequest = OneTimeWorkRequestBuilder<DataSyncWorker>()
    .setInputData(
        workDataOf(DataSyncWorker.KEY_SYNC_DATA to "user_data")
    )
    .build()

WorkManager.getInstance(context).enqueue(workRequest)

7.2 处理相同类型的多个参数

为每个参数添加字符串标识符。标识符必须在构造函数和工厂之间完全匹配。这告诉Hilt如何正确映射参数。

class MessageComposer @AssistedInject constructor(
    private val messagingService: MessagingService, // 注入的
    @Assisted("senderId") private val senderId: String,
    @Assisted("recipientId") private val recipientId: String,
    @Assisted("messageType") private val messageType: String
) 

@AssistedFactory
interface MessageComposerFactory {
    fun create(
        @Assisted("senderId") senderId: String,
        @Assisted("recipientId") recipientId: String,
        @Assisted("messageType") messageType: String
    ): MessageComposer
}

八、Hilt组件的生命周期和作用范围

Hilt组件最大Android类生效范围可指定的作用域默认绑定的依赖类型创建实际~销毁时机
SingletonComponentApplication@SingletonApplicationApplication:onCreate()~已销毁
ActivityRetainedComponent不适用@ActivityRetainedScopeApplicationActivity:onCreate()~onDestroy()
ViewModelComponentViewModel@ViewModelScopeSavedStateHandleViewModel:已创建~已销毁
ServiceComponentService@ServiceScopedApplication、ServiceService:onCreate()~onDestroy()
ActivityComponentActivity@ActivityScopedApplication、ActivityActivity:onCreate()~OnDestroy()
ViewComponentView@ViewScopedApplication、Activity、ViewView:super()~已销毁
FragmentComponentFragment@FragmentScopedApplication、Activity、FragmentFragment:onAttach()~onDestroy()
ViewWithFragmentComponent@WithFragmentBindings 注解的View@ViewScopedApplication、Activity、Fragment、ViewView:super()~已销毁

8.1 组件的生命周期

组件与对应的 Android 类有着相同的生命周期,不然会内存泄漏。

  • ActivityRetainedComponent 在配置更改后仍然存在,因此它在第一次调用 Activity#onCreate() 时创建,在最后一次调用 Activity#onDestroy() 时销毁。
Hilt组件创建时机 - 销毁时机
SingletonComponentApplication#onCreate() - 已销毁
ActivityRetainedComponentActivity#onCreate() - onDestroy()
ViewModelComponentViewModel 已创建 - ViewModel 已销毁
ServiceComponentService#onCreate() - onDestroy()
ActivityComponentActivity#onCreate() - onDestroy()
ViewComponentView#super() - View 已销毁
FragmentComponentFragment#onAttach() - onDestroy()
ViewWithFragmentComponentView#super() - View 已销毁

8.2 组件的作用域范围

默认情况下 Hilt 中所有的绑定都没有限定作用域,也就是每次代码访问字段时都会新建一个实例,当需要共享一个实例时,就需要给绑定限定作用域,即提供的实例在对应的Android类生效范围中为单例(同一个Activity中保持单例,不同Activity中实例不同)。

  • 作用域与组件一一对应,指定不一致报错
Hilt组件作用域
SingletonComponent@Singleton
ActivityRetainedComponent@ActivityRetainedScoped
ViewModelComponent@ViewModelScoped
ServiceComponent@ServiceScoped
ActivityComponent@ActivityScoped
ViewComponent@ViewScoped
FragmentComponent@FragmentScoped
ViewWithFragmentComponent@ViewScoped
@ActivityScoped    //指定作用域
class Demo @Inject constructor() {...}

@Module
@InstallIn(ActivityComponent::class)
class StringModule {
    @ActivityScoped    //指定作用域
    @Provides
    fun providesOneString() = "One"
}

8.3 组件的层次结构

当一个依赖类型的作用域是整个APP,那在Activity中肯定可以访问到,作用域存在包含关系也就是组件存在层次结构。当模块装载到组件后,模块所绑定的依赖类型也可以用于该组件层次结构以下的子组件绑定。

  • ViewComponent 可以使用 ActivityComponent 中绑定的依赖类型,如果还需要使用 FragmentComponent 中的绑定并且视图是 Fragment 的一部分,用该将 @WithFragmentBindings 注解和 @AndroidEntryPoint 一起使用。

8.4 预置Qualifier(获取Context、Activity)

可以使用 @ApplicationContext 和 @ActivityContext 来获得上下文,有参构造如果包含了 Application 或 Activity 不用额外处理。

  • 如果使用了 @Singleton 但构造参数使用了 @ActivityContext 会报错,全局可用却依赖一个更小的Activity的上下文,改成 @ActivityScoped、@FragmentScoped(小依赖大没问题),或者直接删掉都可以。
  • 必须是 Application 或 Activity 类型,它们的子类无法被识别,对于获取自定义 Application 可通过自定义一个 Module 提供,在获取的方法中转换处理。
class Demo @Inject constructor(
    val activity: Activity,
    val app: Application,
    @ActivityContext val context: Context
)
@Module
@InstallIn(SingletonComponent::class)
class MyApplicationModule {
    @Provides
    fun provideMyApplication(application: Application): MyApplication {
        return application as MyApplication
    }
}

class Demo @Inject constructor(
    val app: MyApplication
) {...}

九、ViewModel 中使用 

        给 ViewModel 添加 @HiltViewModel,并对构造函数使用 @Inject。在带有 @AndroidEntryPoint 的 Activity/Fragment 中可以使用 ViewModelProvider 或 by viewModels() 来获取实例。

        实例由 ViewModelComponent 提供,它和 ViewModel 有相同的生命周期,因此可在配置更改后继续存在。如果需要每次访问获取的实例是同一个,使用 @ViewModelScope 限制作用域。

@HiltViewModel
class DemoViewModel @Inject constructor(
    private val avedStateHandle: SavedStateHandle,
    private val repository: DemoRepository
) : ViewModel() {}

@AndroidEntryPoint
class DemoActivity : AppCompatActivity() {
    private val viewModel: DemoViewModel by viewModels()
}

十、Navigation 中使用

另见:Compose中使用

如果 ViewModel 的作用域限定为导航图,使用 hiltNavGraphViewModels( ),该函数可与带有 @AndroidEntryPoint 的Fragment 搭配使用。

implementation 'androidx.hilt:hilt-navigation-fragment:1.0.0'
val viewModel: ExampleViewModel by hiltNavGraphViewModels(R.id.my_graph)

十一、实际使用场景举例

10.1 SharedPreference注入

// 创建一个Hilt模块,用于提供SharedPreferences实例
@Module
@InstallIn(SingletonComponent::class)    //作用范围全局
class SharedPreferencesModule {
    @Singleton    //全局单例
    @Provides
    fun provideSharedPreferences(@ApplicationContext context: Context): SharedPreferences {
        return context.getSharedPreferences("pref_name", Context.MODE_PRIVATE)
    }
}
// 在Activity中,你可以使用@Inject注解来请求一个SharedPreferences实例。
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    @Inject
    lateinit var sharedPreferences: SharedPreferences

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        // 使用SharedPreferences
        val editor = sharedPreferences.edit()
        editor.putString("key", "value")
        editor.apply()
    }
}

10.2 多模块项目

假设有一个data模块和一个app模块,data模块提供了一个Repository类,app模块需要使用这个Repository。

//首先,在data模块中,你定义了一个Repository类,并用@Inject注解标记其构造函数:
class Repository @Inject constructor() {}
//然后,在app模块中,你可以直接在需要的地方注入Repository:
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    @Inject lateinit var repository: Repository
}

10.3 Retrofit

        NetworkModule是一个Hilt模块,它在应用级别的组件(SingletonComponent)中提供了一个Retrofit实例。有一个Repository类,它需要这个Retrofit实例来发起网络请求。有一个ViewModel,它需要这个Repository来获取数据。

        这就是一个典型的依赖链:MyViewModel依赖于Repository,Repository依赖于Retrofit。通过Hilt,我们可以轻松地管理这个依赖链,而无需手动创建和管理每个依赖。这使得代码更加清晰和直观,也使得新成员更容易理解项目的结构。

@Module
@InstallIn(SingletonComponent::class)
class NetworkModule {
    @Singleton
    @Provides
    fun provideRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://api.example.com/")
            .build()
    }
}
class Repository @Inject constructor(private val retrofit: Retrofit) {}
class MyViewModel @ViewModelInject constructor(
    private val repository: Repository
) : ViewModel() {}

10.4 数据库

// 创建一个Hilt模块,用于提供Room数据库实例
@Module
@InstallIn(SingletonComponent::class)
class DatabaseModule {

    @Provides
    fun provideDatabase(@ApplicationContext context: Context): MyDatabase {
        return Room.databaseBuilder(
            context,
            MyDatabase::class.java, "database-name"
        ).build()
    }

    @Provides
    fun provideUserDao(database: MyDatabase): UserDao {
        return database.userDao()
    }
}

// 在需要UserDao的地方,使用@Inject注解来请求一个UserDao实例。
class UserRepository @Inject constructor(private val userDao: UserDao) {
    // ...
}
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值