Android Jetpack(10):WorkManager

WorkManager介绍

执行后台任务,比如app处于后台时执行下载任务。
基本上,WorkManager 能做的,Service 也能做,我并没有想到有什么情况是非用 WorkManger 不可的。
在不久的将来,在某些情况下,Service 已经没卵用了!而 WorkManager 作为一个更合理的后台任务管理库,在这种情况下就是一个更好的选择了。

作为 Android Jetpack 中的新组件,WorkManager 负责用来管理后台任务,说简单点是和异步任务Task或者 Service 作用一样,都可以处理异步任务或后台任务。

WorkManager用于支持Android在后台的任务运行,提供延迟、周期性,约束性需求的后台任务。任务是交给系统统一调度的,适合一些轻量级的后台功能使用。还能支持在Doze模式下运行后台任务,WorkManager会在Doze模式的窗口期运行任务。

WorkManager的设计用意就是取代后台服务,由系统统一管理你的周期性后台服务,并且自动兼容API23以下,API23以下自动在底层使用AlarmManager + BroadcastReceiver实现,而高于API23会使用JobScheduler实现。所以这是一个能取代闹钟定时器的后台功能。并且在高版本里闹钟功能其实已经不太能正常使用了。使用WorkManager取代所有周期或者长时间的后台工作是必需必要的。

相关类

Worker

任务的执行者,是一个抽象类,需要继承它实现要执行的任务。

WorkRequest

指定让哪个 Woker 执行任务,指定执行的环境,执行的顺序等。 要使用它的子类 OneTimeWorkRequest 或PeriodicWorkRequest。

WorkManager

管理任务请求和任务队列,发起的 WorkRequest 会进入它的任务队列。

WorkStatus

包含有任务的状态和任务的信息,以 LiveData 的形式提供给观察者,更新相关UI。

WorkManager使用

依赖

在 build.gradle 添加如下依赖:

		// (Java only)
        implementation "androidx.work:work-runtime:2.3.4"//java 语言选这个

        // Kotlin + coroutines
        implementation "androidx.work:work-runtime-ktx:2.3.4"//kotlin 选这个

        // optional - RxJava2 support
        implementation "androidx.work:work-rxjava2:2.3.4"//配合rxjava2 使用

定义 Worker

我们定义 MainWorker 继承 Worker,发现需要重写 doWork 方法,这个方法是执行后台功能实现的地方,并且需要返回任务的状态 WorkerResult:

/**
 * Cerated by a
 * Create date : 2020/11/20 10:20
 * description : 创建work,继承Worker,实现doWork方法,这个方法是执行后台功能实现的地方。
 */
class MainWorker(context: Context, workerParameters: WorkerParameters) :
    Worker(context, workerParameters) {

    override fun doWork(): Result {
        Log.e("xyh", "doWork: ")
        //要执行的任务
        //...

        return Result.success() //结果返回为成功
    }
}

我们暂时什么都不做,直接返回任务执行完成 WorkerResult.SUCCESS。

在 MainActivity 中创建Work请求(WorkRequest)并且添加到WorkManager里:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val binding =
            DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)

        startWork()

    }

    private fun startWork() {
        //定义WorkRequest,OneTimeWorkRequest 意味着这个任务只需执行一遍。
        val oneTimeWorkRequest = OneTimeWorkRequest.Builder(MainWorker::class.java) //一次性Work请求
            .setInitialDelay(1, TimeUnit.SECONDS) //初始延迟1秒
            .build()
        WorkManager.getInstance(this).enqueue(oneTimeWorkRequest) //添加到WorkManager队列中
    }
}

OneTimeWorkRequest 意味着这个任务只需执行一遍。

加入任务队列后,任务会马上得到执行。但需要注意的是,这句代码的作用是将任务加入任务队列,而不是执行任务,至于区别后面会讲到。

WorkManager.getInstance(this).enqueue(oneTimeWorkRequest)

数据交互

后台任务少不了数据的交互,我们看一下数据是如何传入传出的。

先是在 Activity 传数据给 Worker:

使用 WorkRequest 的 setInputData 方法传递 Data,Data 的使用和 Bundle 差不多。

    private fun startWork() {

        val data: Data = Data.Builder().putString("name", "赵丽颖").build() //创建需要传入的数据,注意不支持序列化数据传入

        //定义WorkRequest,OneTimeWorkRequest 意味着这个任务只需执行一遍。
        val oneTimeWorkRequest = OneTimeWorkRequest.Builder(MainWorker::class.java) //一次性Work请求
            .setInitialDelay(1, TimeUnit.SECONDS) //初始延迟1秒
            .setInputData(data)
            .build()
        WorkManager.getInstance(this).enqueue(oneTimeWorkRequest) //添加到WorkManager队列中
    }
}

在 Worker 中,从 inputData 可以取到数据,这里取到后简单打印一下:

class MainWorker(context: Context, workerParameters: WorkerParameters) :
    Worker(context, workerParameters) {

    override fun doWork(): Result {

        val name = inputData.getString("name")
        Log.e("xyh", "doWork:$name ")
        
        //要执行的任务
        //...

        return Result.success() //结果返回为成功
    }
}

当任务处理完了,需要将处理结果返回。传入的是 inputData,传出就是 outputData:

work的返回结果与监听状态

处理了后台任务后总会有成功与否的结果。

在doWork方法里返回结果:

一共有3种结果可以返回,如下注释:

 @NonNull
    @Override
    public Result doWork() {
        return Result.success();
//        Result.success();//成功
//        Result.failure();//失败
//        Result.retry();//重试
    }

另外成功与失败的结果还能携带数据返回:

class MainWorker(context: Context, workerParameters: WorkerParameters) :
    Worker(context, workerParameters) {

    override fun doWork(): Result {

        //获取传过来的数据
        val name = inputData.getString("name")
        Log.e("xyh", "doWork:$name ")

        //要执行的任务
        //...

        //返回的数据
        val resultData: Data = Data.Builder().putString("result", "返回数据").build()
        return Result.success(resultData) //结果返回为成功
    }
}

Activity里监听work的状态:

请注意!每一个 WorkRequest 都会有一个 id,在这里的监听的返回的数据是LiveData。 这意味着只有当前Activity或者Fragment在前台时才能接收到此数据。

    private fun startWork() {
        val data: Data = Data.Builder().putString("name", "赵丽颖").build() //创建需要传入的数据,注意不支持序列化数据传入

        //定义WorkRequest,OneTimeWorkRequest 意味着这个任务只需执行一遍。
        val oneTimeWorkRequest = OneTimeWorkRequest.Builder(MainWorker::class.java) //一次性Work请求
            .setInitialDelay(3, TimeUnit.SECONDS) //初始延迟1秒
            .setInputData(data)
            .build()
        WorkManager.getInstance(this).getWorkInfoByIdLiveData(oneTimeWorkRequest.id)
            .observe(this, Observer {
                when (it.state) {
                    WorkInfo.State.BLOCKED -> Log.e("xyh", "堵塞")
                    WorkInfo.State.RUNNING -> Log.e("xyh", "正在运行")
                    WorkInfo.State.ENQUEUED -> Log.e("xyh", "任务入队")
                    WorkInfo.State.CANCELLED -> Log.e("xyh", "取消")
                    WorkInfo.State.FAILED -> Log.e("xyh", "失败")
                    WorkInfo.State.SUCCEEDED -> {
                        Log.e("xyh", "成功:" + it.outputData.getString("result"));
                    }
                }
            })

        WorkManager.getInstance(this).enqueue(oneTimeWorkRequest);//添加到WorkManager队列中
    }

打印结果:

2020-11-20 11:03:33.148 9168-9168/com.zly.jetpack E/xyh: 任务入队
2020-11-20 11:03:35.335 9168-9208/com.zly.jetpack E/xyh: doWork:赵丽颖 
2020-11-20 11:03:35.366 9168-9168/com.zly.jetpack E/xyh: 正在运行
2020-11-20 11:03:35.377 9168-9168/com.zly.jetpack E/xyh: 成功:返回数据

不选择监听,即刻获得某个Work的当前状态值

   private fun startWork() {
        val data: Data = Data.Builder().putString("name", "赵丽颖").build() //创建需要传入的数据,注意不支持序列化数据传入

        //定义WorkRequest,OneTimeWorkRequest 意味着这个任务只需执行一遍。
        val oneTimeWorkRequest = OneTimeWorkRequest.Builder(MainWorker::class.java) //一次性Work请求
            .setInitialDelay(3, TimeUnit.SECONDS) //初始延迟1秒
            .setInputData(data)
            .build()

        WorkManager.getInstance(this).enqueue(oneTimeWorkRequest);//添加到WorkManager队列中

        val workInfo = WorkManager.getInstance(this).getWorkInfoById(oneTimeWorkRequest.id).get()
        Log.e("xyh", "startWork: " + workInfo.state)
    }

work的进度发布

有时候有需求需要知道work任务执行的进度。下面的MyWork模拟发送耗时任务进度,使用setProgressAsync方法发布进度。

public class MyWork extends Worker {

    public MyWork(@NonNull Context context, @NonNull WorkerParameters workerParams) {
        super(context, workerParams);
    }

    @NonNull
    @Override
    public Result doWork() {
        for (int i = 0; i < 10; i++) {
            Data data = new Data.Builder().putInt("Progress", i).build();
            setProgressAsync(data); //发布进度
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        return Result.success();
    }
}

监听进度:

private void startWork() {
        OneTimeWorkRequest oneTimeWorkRequest = new OneTimeWorkRequest.Builder(MyWork.class)//一次性Work请求
                .setInitialDelay(5, TimeUnit.SECONDS)
                .build();
        WorkManager.getInstance(this).getWorkInfoByIdLiveData(oneTimeWorkRequest.getId()).observe(this, new Observer<WorkInfo>() {
            @Override
            public void onChanged(WorkInfo workInfo) {
                if (workInfo.getState() == WorkInfo.State.RUNNING) {
                    Log.e("xyh", "当前进度 = " + workInfo.getProgress().getInt("Progress", -1)); //获取进度
                }
            }
        });
        WorkManager.getInstance(this).enqueue(oneTimeWorkRequest);//添加到WorkManager队列中
    }

Work的停止(取消任务)

work的停止,只会work在运行时执行onStopped,已经执行完成去取消任务是不会触发onStopped方法的。

需要取消一个在队列中的任务,是通过 id 实现的。

 WorkManager.getInstance(MainActivity.this).cancelWorkById(oneTimeWorkRequest.getId());//取消任务

work里的代码:

public class MyWork extends Worker {
    private boolean mIsStop = false;
    public MyWork(@NonNull Context context, @NonNull WorkerParameters workerParams) {
        super(context, workerParams);
    }

    @NonNull
    @Override
    public Result doWork() {
        for (int i = 0; i < 10; i++) {
            if (mIsStop){
                break;
            }
            Data data = new Data.Builder().putInt("Progress", i).build();
            setProgressAsync(data);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        return Result.success();
    }

    @Override
    public void onStopped() {
        Log.e("xyh", "this_onStopped");
        mIsStop = true;
        super.onStopped();
    }
}

Activity代码:

public class MainActivity extends AppCompatActivity {
    private ActivityMianDemoBinding mBinding;
    private OneTimeWorkRequest oneTimeWorkRequest;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mBinding = ActivityMianDemoBinding.inflate(getLayoutInflater());
        setContentView(mBinding.getRoot());
        mBinding.btn1.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                oneTimeWorkRequest = new OneTimeWorkRequest.Builder(MyWork.class)
                        .setInitialDelay(5, TimeUnit.SECONDS)
                        .build();
                WorkManager.getInstance(MainActivity.this).getWorkInfoByIdLiveData(oneTimeWorkRequest.getId()).observe(MainActivity.this, new Observer<WorkInfo>() {
                    @Override
                    public void onChanged(WorkInfo workInfo) {
                        switch (workInfo.getState()) {
                            case RUNNING:
                                Log.e("“xyh”", "当前进度 = " + workInfo.getProgress().getInt("Progress", -1));
                                break;
                            case CANCELLED:
                                Log.e("xyh", "this_取消任务");
                                break;
                        }
                    }
                });
                WorkManager.getInstance(MainActivity.this).enqueue(oneTimeWorkRequest);

            }
        });
        mBinding.btn2.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                WorkManager.getInstance(MainActivity.this).cancelWorkById(oneTimeWorkRequest.getId());//取消任务
            }
        });
    }
}

创建Work请求(WorkRequest)的2种方式

  • OneTimeWorkRequest : 一次性Work请求
  • PeriodicWorkRequest:周期性Work请求

OneTimeWorkRequest:一次性Work请求

设置初始延迟时间 setInitialDelay

上面已经有很多例子了,就不在重复说明了:

       //定义WorkRequest,OneTimeWorkRequest 意味着这个任务只需执行一遍。
        val oneTimeWorkRequest = OneTimeWorkRequest.Builder(MainWorker::class.java) //一次性Work请求
            .setInitialDelay(3, TimeUnit.SECONDS) //初始延迟1秒
            .setInputData(data)
            .build()

设置传入数据 setInputData:

        val data: Data = Data.Builder().putString("name", "赵丽颖").build() //创建需要传入的数据,注意不支持序列化数据传入

        //定义WorkRequest,OneTimeWorkRequest 意味着这个任务只需执行一遍。
        val oneTimeWorkRequest = OneTimeWorkRequest.Builder(MainWorker::class.java) //一次性Work请求
            .setInitialDelay(3, TimeUnit.SECONDS) //初始延迟1秒
            .setInputData(data) //设置传入数据
            .build()

添加Tag addTag

注意,这个添加Tag有点奇怪。如果一直添加相同的Tag,这个Tag可以被多次添加,并且在使用getWorkInfosByTagLiveData 进行监听回调时List也会有多个,并且无法好像无法删除这个list数量(取消任务也不行)。但是只会返回一次数据。请谨慎使用,我暂时没明白如何使用它。

OneTimeWorkRequest oneTimeWorkRequest = new OneTimeWorkRequest.Builder(MyWork.class)
                        .setInitialDelay(2, TimeUnit.SECONDS)//设置初始延时时间
                        .addTag("tag1")//添加TAG
                        .build();
                WorkManager.getInstance(MainActivity.this).getWorkInfosByTagLiveData("tag1").observe(MainActivity.this, new Observer<List<WorkInfo>>() {
                    @Override
                    public void onChanged(List<WorkInfo> workInfos) {
                        if (workInfos == null && workInfos.isEmpty()) {
                            return;
                        }
                        Log.e("调试_临时_log", "this_ workInfos.size = " + workInfos.size());
                        WorkInfo workInfo = workInfos.get(0);
                        switch (workInfo.getState()) {
                            case RUNNING:
                                Log.e("调试_临时_log", "this_进行中");
                                break;
                            case CANCELLED:
                                Log.e("调试_临时_log", "this_取消");
                                break;
                            case SUCCEEDED:
                                Log.e("调试_临时_log", "this_成功");
                                break;
                        }
                    }
                });
                WorkManager.getInstance(MainActivity.this).enqueue(oneTimeWorkRequest);

设置任务的结果保存时间 keepResultsForAtLeast

oneTimeWorkRequest = new OneTimeWorkRequest.Builder(MyWork.class)
                        .keepResultsForAtLeast(10, TimeUnit.MINUTES)//结果延迟保存
                        .build();

设置退避策略 setBackoffCriteria

一般当我们任务执行失败的时候任务需要重试的时候会用到这个函数,在任务执行失败的时候Worker类的doWork()函数返回Result.RETRY告诉这个任务要重试。那重试的策略就是通过setBackoffCriteria()函数来设置的。

BackoffPolicy有两个值:

BackoffPolicy.LINEAR(每次重试的时间线性增加,比如第一次10分钟,第二次就是20分钟)

BackoffPolicy.EXPONENTIAL(每次重试时间指数增加)。

oneTimeWorkRequest = new OneTimeWorkRequest.Builder(MyWork.class)
                        .setBackoffCriteria(BackoffPolicy.LINEAR, 10,TimeUnit.MINUTES)//退避策略 线性增加 10分钟重试
                        .build(); 

PeriodicWorkRequest:周期性Work请求

前面说了 OneTimeWorkRequest 是指任务只需要执行一遍,而 PeriodicWorkRequest 则可以发起一个多次执行的定时任务:

val request = PeriodicWorkRequest
        .Builder(MainWorker::class.java, 15, TimeUnit.MINUTES)
        .setConstraints(constraints)
        .setInputData(data)
        .build()

这样,发起的任务就会每隔 15 分钟执行一次。除了需要传入间隔时间,使用起来跟 OneTimeWorkRequest 是没有区别的。

你可能会想更频繁的去执行一个任务,比如几秒钟执行一遍,但很遗憾,最小时间间隔就是 15 分钟,看一下源码就知道了。

还有需要注意的是,定时任务并不是说经过指定时间后它就马上执行,而是经过这一段时间后,等到满足约束条件等情况时,它才执行。

环境约束Constraints

WorkManager 允许我们指定任务执行的环境,比如网络已连接、电量充足时等,在满足条件的情况下任务才会执行。

可指定的条件及设置方法如下:

        Constraints constraints = new Constraints();
        
        //设备存储空间充足的时候 才能执行 ,>15%
        constraints.setRequiresStorageNotLow(true);
        //必须在执行的网络条件下才能好执行,不计流量 ,wifi
        constraints.setRequiredNetworkType(NetworkType.UNMETERED);
        //设备的充电量充足的才能执行 >15%
        constraints.setRequiresBatteryNotLow(true);
        //只有设备在充电的情况下 才能允许执行
        constraints.setRequiresCharging(true);
        //只有设备在空闲的情况下才能被执行 比如息屏,cpu利用率不高
        constraints.setRequiresDeviceIdle(true);
        //workmanager利用contentObserver监控传递进来的这个uri对应的内容是否发生变化,当且仅当它发生变化了
        //我们的任务才会被触发执行,以下三个api是关联的
        constraints.setContentUriTriggers(null);
        //设置从content变化到被执行中间的延迟时间,如果在这期间。content发生了变化,延迟时间会被重新计算
//        这个content就是指 我们设置的setContentUriTriggers uri对应的内容
        constraints.setTriggerContentUpdateDelay(0);
        //设置从content变化到被执行中间的最大延迟时间  这个content就是指 我们设置的 
        constraints.setContentUriTriggers uri对应的内容
        constraints.setTriggerMaxContentDelay(0);

                OneTimeWorkRequest oneTimeWorkRequest = new OneTimeWorkRequest.Builder(MyWork.class)
                        .setConstraints(constraints)
                        .build();
                WorkManager.getInstance(MainActivity.this).enqueue(oneTimeWorkRequest);

网络状态条件:
在这里插入图片描述
我们试一下效果,添加一个需要联网的条件,打开应用之前,先把网络关闭,打开后发现 Worker 并没有打印时间,这时候再把网连上,就会看到打印出时间了。这也是为什么前面说 WorkManager.getInstance().enqueue(request) 是将任务加入任务队列,并不代表马上执行任务,因为任务可能需要等到满足环境条件的情况才会执行。

WorkManager强大的生命力

就算进程被杀掉,任务还是存在,甚至如果重启手机,任务依然会在满足条件的情况下得到执行。

这是 WorkManager 的另一个特点,一旦发起一个任务,任务是可以保证一定会被执行的,就算退出应用,甚至重启手机都阻止不了他。但可能由于添加了环境约束等原因,它执行的时间是不确定的。

当应用正在运行时,它会在当前的进程中启用一个子线程执行。应用没有运行的情况下启用,它则会自己选择一种合适的方式在后台运行。具体是什么方式和 Android 的版本和依赖环境有关:

在这里插入图片描述

任务链

WorkManager 允许我们按照一定的顺序执行任务,比如我想 A、B、C 三个任务按先后顺序执行:

在这里插入图片描述

可以这样写,把它们组成一条任务链:

WorkManager.getInstance()
        .beginWith(workA)
        .then(workB)
        .then(workC)
        .enqueue()

这样的话,上一个任务的 outputData (执行任务返回的数据)会成为下一个任务的 inputData(传入到任务的数据)。

再更复杂一点,我想 A 和 B 同时执行,它们都执行完之后,再执行 C:

在这里插入图片描述

WorkManager.getInstance()
        .beginWith(workA,workB)
        .then(workC)
        .enqueue()

再更更复杂一点,如果我想这样:

在这里插入图片描述
这样就需要先把 A、B 和 C、D 分别组成一条任务链,再进行联结:

val chain1 = WorkManager.getInstance()
        .beginWith(workA)
        .then(workB)
val chain2 = WorkManager.getInstance()
        .beginWith(workC)
        .then(workD)
val chain3 = WorkContinuation
        .combine(chain1, chain2)
        .then(workE)
chain3.enqueue()

再更更更复杂一点,如果我把定时任务放进去会怎样?不好意思,链式任务只支持 OneTimeWorkRequest

使用任务链,我们可以将各种任务进行模块化。同样的,任务链不保证每个任务执行的时间,但是保证它们执行的先后顺序。

WorkManager

主要用于work的入队与取消,设置监听功能。

任务入队

WorkManager.getInstance(MainActivity.this).enqueue(oneTimeWorkRequest);

取消指定ID任务

WorkManager.getInstance(MainActivity.this).cancelWorkById(oneTimeWorkRequest.getId());

取消全部任务

WorkManager.getInstance(MainActivity.this).cancelAllWork();

创建唯一任务

很多情况下,我们希望在任务队列里,同一个任务只存在一个,避免任务的重复执行,这时候可以用到 beginUniqueWork 这个方法:

WorkManager.getInstance(MainActivity.this).beginUniqueWork("unique", ExistingWorkPolicy.REPLACE, oneTimeWorkRequest).enqueue();
或者
WorkManager.getInstance(MainActivity.this).enqueueUniqueWork("unique", ExistingWorkPolicy.REPLACE, oneTimeWorkRequest);

需要传入一个任务的标签,和重复任务的执行方式,可取值如下:

在这里插入图片描述
但这种方式也是只支持 OneTimeWorkRequest。如果是 PeriodicWorkRequest,我想到的办法是每次执行之前,根据标签去取消已有的任务。

监听唯一任务

WorkManager.getInstance(MainActivity.this).getWorkInfosForUniqueWorkLiveData("unique").observe(MainActivity.this, new Observer<List<WorkInfo>>() {
                    @Override
                    public void onChanged(List<WorkInfo> workInfos) {

                    }
                });

WorkManager使用场景

WorkManager 区别于异步任务,它更像是一个 Service。基本上,WorkManager 能做的,Service 也能做,我并没有想到有什么情况是非用 WorkManger 不可的。

但反观 Service,泛滥的 Service 后台任务可能是引起 Android 系统卡顿的主要原因,这几年 Google 也对 Service 也做了一些限制。

说了这么多,我想表达的是,在不久的将来,在某些情况下,Service 已经没卵用了!而 WorkManager 作为一个更合理的后台任务管理库,在这种情况下就是一个更好的选择了。

对 Service 的限制

Android 6.0 (API 23)

休眠模式:在关闭手机屏幕后,系统会禁止应用的网络请求等功能。

Android 8.0(API 26)

在某些不被允许的情况下,调用 startService 会抛异常。

但目前很多 APP 的 target API 还在 23 以下,因为不想处理运行时权限,更别说 API 26 了。基于此,2017 年年底,谷歌采取了少有的强硬措施。

对 Target API 的要求

2018 年 8 月起

所有新开发的应用,Target API 必须是 26 或以上。

2018 年 11 月起

所有已发布的应用,Target API 必须更新到 26 或以上。

2019 年起

每次发布新版本后,所有应用都必须在一年内将 Target API 更新到最新版本

虽然这些措施对国内的环境没有办法造成直接影响,但这也会成为一种发展指导方向。

保活?

这里引入一个思考,既然 WorkManager 的生命力这么强,还可以实现定时任务,那能不能让我们的应用生命力也这么强?换句话说,能不能用它来保活?

要是上面有细看的话,你应该已经发现这几点了:

  • 定时任务有最小间隔时间的限制,是 15 分钟
  • 只有程序运行时,任务才会得到执行
  • 无法拉起 Activity

总之,用 WorkManager 保活是不可能了,这辈子都不可能保活了。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值