Android WorkManager使用以及源码分析
1、前言
WorkManager 是适合用于持久性工作的推荐解决方案。如果工作始终要通过应用重启和系统重新启动来调度,便是持久性的工作。由于大多数后台处理操作都是通过持久性工作完成的,因此 WorkManager 是适用于后台处理操作的主要推荐 API。 WorkManager 可处理三种类型的持久性工作:
- 立即执行:必须立即开始且很快就完成的任务,可以加急。
- 长时间运行:运行时间可能较长(有可能超过 10 分钟)的任务。
- 可延期执行:延期开始并且可以定期运行的预定任务。
2、使用
2.1、引用
implementation "androidx.work:work-runtime:2.7.1" //基础使用 implementation "androidx.work:work-multiprocess:2.7.1" //跨进程时引用
2.2 使用
执行一次性任务
Data data = new Data.Builder().putBoolean("is_test", false).build(); WorkManager.getInstance(this).enqueue( new OneTimeWorkRequest.Builder(Test.class) // 执行任务一次性任务 .setInputMerger(NewInputMerge.class) // 输入数据合并策略,这里并没有用,链式处理时,多个上流执行结果合并,作为下流输入数据 .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 5, TimeUnit.MINUTES) // 重试策略 .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) // 加急处理 .addTag("test").addTag("huahua") // 标识 .setInputData(data) // 输入数据 .setInitialDelay(5, TimeUnit.SECONDS) // 执行延时时间 .setConstraints(new Constraints.Builder().setRequiresBatteryNotLow(true).build()) // 约束,部分约束只对高版本有效 .build() );
执行周期性任务
WorkManager.getInstance(this).enqueue( new PeriodicWorkRequest.Builder(Test.class, 2, TimeUnit.HOURS) // 执行周期性任务,周期2小时 .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 5, TimeUnit.MINUTES) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .addTag("test").addTag("huahua") .setInputData(data) .setInitialDelay(5, TimeUnit.SECONDS) // 首次执行延时时间 .setConstraints(new Constraints.Builder().setRequiresBatteryNotLow(true).build()) .build() );
拥有名字的任务
WorkManager.getInstance(this).enqueueUniqueWork("test", ExistingWorkPolicy.KEEP, new OneTimeWorkRequest.Builder(Test.class).build()); // 此种方法会对重复名字的任务进行处理
监听状态
WorkManager.getInstance(this).getWorkInfosByTagLiveData("test").observe(this, new Observer<List<WorkInfo>>() { @Override public void onChanged(List<WorkInfo> workInfos) { } });
定义Work代码
public class Test extends Worker { public Test(@NonNull Context context, @NonNull WorkerParameters workerParams) { super(context, workerParams); Data input = getInputData(); // 获取输入数据 boolean isTest = input.getBoolean("is_test", true); } @NonNull @Override public Result doWork() { return Result.success(); } }
继承使Worker类,实现doWork()方法,此方法是实现任务的主体;doWork()返回的 Result会通知 WorkManager 服务工作是否成功,以及工作失败时是否应重试工作。
- Result.success():工作成功完成。
- Result.failure():工作失败。
- Result.retry():工作失败,应根据其重试政策在其他时间尝试。
配置初始化 不同的版本初始化不同,但是都是通过Provider来进行的,2.6之前是WorkManagerInitializer, 2.6之后是InitializationProvider;这里按照2.7.1的版本来说,老版本有需要留言回复;有两种处理方案
- 移除默认Provider
- 按照Provider流程来进行
按照提供初始化流程处理
1.首先注意一个字符串配置:这个很重要,不建议修改
<string name="androidx_startup" translatable="false">androidx.startup</string>
2.提供实现Initializer的类,这个类是被调用的关键
3.移除默认实现
<provider android:name="androidx.startup.InitializationProvider" android:authorities="${applicationId}.androidx-startup" android:exported="false" tools:node="merge"> <meta-data android:name="androidx.work.WorkManagerInitializer" android:value="androidx.startup" tools:node="remove" /> // 这表示移除 </provider>
默认实现Initializer类WorkManagerInitializer,使用的默认configuration
public final class WorkManagerInitializer implements Initializer<WorkManager> { private static final String TAG = Logger.tagWithPrefix("WrkMgrInitializer"); @NonNull @Override public WorkManager create(@NonNull Context context) { Logger.get().debug(TAG, "Initializing WorkManager with default configuration."); WorkManager.initialize(context, new Configuration.Builder().build()); // 这里提供Configuration return WorkManager.getInstance(context); } @NonNull @Override public List<Class<? extends androidx.startup.Initializer<?>>> dependencies() { return Collections.emptyList(); // 提供需要执行的其它Initializer } }
4.进行注册自定义实现; 在meta-data数据处理,key为你需要调用的初始化类,value必须为R.sting.androidx_startup这个字符串的值; 如下
<provider android:name="androidx.startup.InitializationProvider" android:authorities="${applicationId}.androidx-startup" android:exported="false" tools:node="merge" > <meta-data android:name="androidx.work.WorkManagerInitializer" // 这里为你的实现的Initializer android:value="androidx.startup" /> // 这里必须与R.sting.androidx_startup保持一致 </provider>
2.3 重要概念
任务标识
- id : 通过WorkRequest对象getStringId获取,每次添加一次任务,就会得到唯一的id
- name :任务名字,暂时一次性任务才可以有; 一个任务最多一个名字,而一个名字可以对应多个任务; 同名字任务可以通过定义不同名字冲突来解决
- tag: 一个任务可以拥有多个tag,一个tag可以对应多个任务
任务类型
- 一次性任务: 仅仅执行一次, 如果结果返回retry时,按照重试退避政策,进行重试,直至成功
- 周期性任务: 按照周期执行,无论结果返回什么情况,每次周期内仅仅执行一次
任务链
使用 WorkManager 创建工作链并将其加入队列。工作链用于指定多个依存任务并定义这些任务的运行顺序。使用 WorkManager.beginWith(OneTimeWorkRequest 或 WorkManager.beginWith(List)返回WorkContinuation实例,然后通过其 then(OneTimeWorkRequest)或 then(List)添加任务链,最后使用WorkContinuation.enqueue()进行执行。
任务链先添加的任务为后续任务的先决条件, 也就是前面任务成功了后面任务才会执行
重试退避政策
针对worker执行返回Result.retry()时处理策略, 使用方法setBackoffCriteria设置, 有两个指标,策略和延时时间(允许的最小值,即 10 秒);情况有下面2种
- 线性LINEAR: 每次失败后重新开启任务时间延时为 延时时间次数倍(延时时间 * n)
- EXPONENTIAL: 每次失败后重新开启任务时间延时为 延时时间的指数倍(延时时间 * 2^n)
输入合并器
针对一次性任务,且在工作链中,父级工作请求的输出将作为子级的输入传入的场景中使用。输入中会存在相同关键字,这时,输入就会存在冲突
WorkManager 提供两种不同类型的 InputMerger:
- OverwritingInputMerger:会尝试将所有输入中的所有键添加到输出中。如果发生冲突,它会覆盖先前设置的键。
- ArrayCreatingInputMerger: 会尝试合并输入,并在必要时创建数组。
冲突解决政策
调度唯一工作时,发生冲突时要执行的操作。可以通过在将工作加入队列时传递一个枚举来实现此目的。
对于一次性工作,您需要提供一个 ExistingWorkPolicy,有4 个选项。
- REPLACE:用新工作替换现有工作。此选项将取消现有工作。
- KEEP:保留现有工作,并忽略新工作。
- APPEND:将新工作附加到现有工作的末尾。此政策将导致您的新工作到现有工作,在现有工作完成后运行。 现有工作将成为新工作的先决条件。如果现有工作变为
CANCELLED
或FAILED
状态,新工作也会变为CANCELLED
或FAILED
。如果您希望无论现有工作的状态如何都运行新工作,请改用APPEND_OR_REPLACE
。 - APPEND_OR_REPLACE: 类似于 APPEND,不过它并不依赖于先决条件工作状态。**即使现有工作变为
CANCELLED
或FAILED
状态,新工作仍会运行。
约束条件
任务执行的先决条件,使用Contraints.Builder()进行构建实例。有一下约束
- NetworkType : 约束运行工作所需的网络类型。
- BatteryNotLow : 如果设置为 true,那么当设备处于“电量不足模式”时,工作不会运行。
- RequiresCharging : 如果设置为 true,那么工作只能在设备充电时运行。
- DeviceIdle:如果设置为 true,则要求用户的设备必须处于空闲状态,才能运行工作。在运行批量操作时,此约束会非常有用;若是不用此约束,批量操作可能会降低用户设备上正在积极运行的其他应用的性能。
- StorageNotLow: 如果设置为 true,那么当用户设备上的存储空间不足时,工作不会运行。
还有以下约束,对于>=24的版本有效:
- setContentUriTriggers方法: 设置触发任务的uri
- setTriggerContentUpdateDelay方法:设置触发执行的延迟时间
- setTriggerMaxContentDelay方法:设置处罚执行的最大延时
3、原理
合理分为几个部分来说
- 1. 约束检测: 约束检测的逻辑以及实现
- 2. 任务调度: 3种调度器, alarm、greedy、JobScheduler; 用于唤起任务执行
- 3. 任务执行流程 :请求的包装,任务如何加入调度器,以及调度完成后执行任务详情
这里有需要理解的一个技术点:SettableFuture,实现了ListenableFuture接口,并且增加了下面接口ListenableFuture。这个类在源码中频繁使用
ListenableFuture只有一个方法
void addListener(Runnable listener, Executor executor)
调用了这个方法,这个类才有使用意义;调用之后,表示SettableFuture若完成,则使用executor执行listener
另外一个让我觉得有意思的地方,或者说不同之前future的地方是, SettableFuture未实现Runnable接口,也就是其结果不是来源于自己,来源于调用下面3个方法
public boolean set(@Nullable V value) // 正常设置结果 public boolean setException(Throwable throwable) // 执行结果异常 public boolean setFuture(ListenableFuture<? extends V> future) // 设置执行结果来源,也就是ListenableFuture的执行结果
3.1 约束检测
类图如下
结合源码分析,结论有:
- WorkConstraintsTracker类:使用入口,构造器传入WorkConstraintsCallback为结果回调,也可以直接调用方法areAllConstraintsMet来判断是否满足约束
- ConstraintController类:一种约束控制器类,通过OnConstraintUpdatedCallback回调实时结果给WorkConstraintsTracker, 通过ConstraintListener与ConstraintTracker联系
- ConstraintTracker:实际约束值的检测者,ConstraintListener回调实时传递值与ConstraintController;通过setState触发回调
实际检测技术点,也就是ConstraintTracker实现
3.1.1 StorageNotLowTracker类
存储空间的是否低,根据系统广播来处理
Intent.ACTION_DEVICE_STORAGE_OK // 存储空间够用 Intent.ACTION_DEVICE_STORAGE_LOW // 存储空间很低
初步检测状态:
@Override public Boolean getInitialState() { Intent intent = mAppContext.registerReceiver(null, getIntentFilter()); if (intent == null || intent.getAction() == null) { return true; } else { switch (intent.getAction()) { case Intent.ACTION_DEVICE_STORAGE_OK: return true; case Intent.ACTION_DEVICE_STORAGE_LOW: return false; default: return null; } } }
实时更新通过接收广播信息:
public void onBroadcastReceive(Context context, @NonNull Intent intent) { if (intent.getAction() == null) { return; } switch (intent.getAction()) { case Intent.ACTION_DEVICE_STORAGE_OK: setState(true); break; case Intent.ACTION_DEVICE_STORAGE_LOW: setState(false); break; } }
3.1.2 BatteryChargingTracker类
充电状态,同样通过广播来进行处理;
接收广播根据版本不同而不同如下:
public IntentFilter getIntentFilter() { IntentFilter intentFilter = new IntentFilter(); if (Build.VERSION.SDK_INT >= 23) { intentFilter.addAction(BatteryManager.ACTION_CHARGING); intentFilter.addAction(BatteryManager.ACTION_DISCHARGING); } else { intentFilter.addAction(Intent.ACTION_POWER_CONNECTED); intentFilter.addAction(Intent.ACTION_POWER_DISCONNECTED); } return intentFilter; }
初始状态:
@Override public Boolean getInitialState() { IntentFilter intentFilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED); Intent intent = mAppContext.registerReceiver(null, intentFilter); if (intent == null) { Logger.get().error(TAG, "getInitialState - null intent received"); return null; } return isBatteryChangedIntentCharging(intent); } private boolean isBatteryChangedIntentCharging(Intent intent) { boolean charging; if (Build.VERSION.SDK_INT >= 23) { int status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1); charging = (status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL); } else { int chargePlug = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0); charging = (chargePlug != 0); } return charging; }
实时状态:
@Override public void onBroadcastReceive(Context context, @NonNull Intent intent) { String action = intent.getAction(); if (action == null) { return; } Logger.get().debug(TAG, String.format("Received %s", action)); switch (action) { case BatteryManager.ACTION_CHARGING: setState(true); break; case BatteryManager.ACTION_DISCHARGING: setState(false); break; case Intent.ACTION_POWER_CONNECTED: setState(true); break; case Intent.ACTION_POWER_DISCONNECTED: setState(false); break; } }
3.1.3 BatteryNotLowTracker类
电量是否低,同样通过广播实时获取状态
Intent.ACTION_BATTERY_OKAY //电量正常 Intent.ACTION_BATTERY_LOW //电量低
初始状态:
@Override public Boolean getInitialState() { IntentFilter intentFilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED); Intent intent = mAppContext.registerReceiver(null, intentFilter); if (intent == null) { Logger.get().error(TAG, "getInitialState - null intent received"); return null; } int status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1); int level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); int scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1); float batteryPercentage = level / (float) scale; return (status == BatteryManager.BATTERY_STATUS_UNKNOWN || batteryPercentage > BATTERY_LOW_THRESHOLD); // 这里是小于15%算是低 }
实时更新:
@Override public void onBroadcastReceive(Context context, @NonNull Intent intent) { if (intent.getAction() == null) { return; } Logger.get().debug(TAG, String.format("Received %s", intent.getAction())); switch (intent.getAction()) { case Intent.ACTION_BATTERY_OKAY: setState(true); break; case Intent.ACTION_BATTERY_LOW: setState(false); break; } }
3.1.4 NetworkStateTracker类
网络状态,这个根据版本不同使用也不同
- 大于等于24时, ConnectivityManager.registerDefaultNetworkCallback(NetworkCallback networkCallback) 来进行监听网络状态变化
- 小于24时,通过广播ConnectivityManager.CONNECTIVITY_ACTION来进行处理
初始状态:
NetworkState getActiveNetworkState() { NetworkInfo info = mConnectivityManager.getActiveNetworkInfo(); boolean isConnected = info != null && info.isConnected(); boolean isValidated = isActiveNetworkValidated(); boolean isMetered = ConnectivityManagerCompat.isActiveNetworkMetered(mConnectivityManager); boolean isNotRoaming = info != null && !info.isRoaming(); return new NetworkState(isConnected, isValidated, isMetered, isNotRoaming); }
实时状态: 在回调NetworkCallback或者接收到广播onReceive时,调用getActiveNetworkState得到
3.2 任务调度器
实现Scheduler接口,调度器有3个:
- SystemJobScheduler: 使用JobSceuler来完成任务唤起;小于23版本时使用
- SystemAlarmScheduler: 使用Alarm来完成任务唤起;大于等于23版本使用
- GreedyScheduler:在进程内直接调用,不同android版本均会建立 其实还有一个androidx.work.impl.background.gcm.GcmScheduler,这个实现需要借助google的GCM推送服务来实现,这里用了反射(国内不支持),并且优先于SystemAlarmScheduler
调度器的创建在WorkManagerImpl类
public List<Scheduler> createSchedulers( @NonNull Context context, @NonNull Configuration configuration, @NonNull TaskExecutor taskExecutor) { return Arrays.asList( Schedulers.createBestAvailableBackgroundScheduler(context, this), // 可以被系统唤起调用手段 new GreedyScheduler(context, configuration, taskExecutor, this)); // 进程内调用手段 }
调度器被调用的逻辑在Schedulers
public static void schedule(Configuration configuration,WorkDatabase workDatabase,List<Scheduler> schedulers) { if (schedulers == null || schedulers.size() == 0) { return; } WorkSpecDao workSpecDao = workDatabase.workSpecDao(); List<WorkSpec> eligibleWorkSpecsForLimitedSlots; List<WorkSpec> allEligibleWorkSpecs; workDatabase.beginTransaction(); try { eligibleWorkSpecsForLimitedSlots = workSpecDao.getEligibleWorkForScheduling(configuration.getMaxSchedulerLimit()); allEligibleWorkSpecs = workSpecDao.getAllEligibleWorkSpecsForScheduling(MAX_GREEDY_SCHEDULER_LIMIT); if (eligibleWorkSpecsForLimitedSlots != null && eligibleWorkSpecsForLimitedSlots.size() > 0) { long now = System.currentTimeMillis(); for (WorkSpec workSpec : eligibleWorkSpecsForLimitedSlots) { workSpecDao.markWorkSpecScheduled(workSpec.id, now); // 进行插槽状态处理,非默认才会被GreedyScheduler进行调度 } } workDatabase.setTransactionSuccessful(); } finally { workDatabase.endTransaction(); } if (eligibleWorkSpecsForLimitedSlots != null && eligibleWorkSpecsForLimitedSlots.size() > 0) { WorkSpec[] eligibleWorkSpecsArray = new WorkSpec[eligibleWorkSpecsForLimitedSlots.size()]; eligibleWorkSpecsArray = eligibleWorkSpecsForLimitedSlots.toArray(eligibleWorkSpecsArray); for (Scheduler scheduler : schedulers) { if (scheduler.hasLimitedSchedulingSlots()) { scheduler.schedule(eligibleWorkSpecsArray); } } } if (allEligibleWorkSpecs != null && allEligibleWorkSpecs.size() > 0) { WorkSpec[] enqueuedWorkSpecsArray = new WorkSpec[allEligibleWorkSpecs.size()]; enqueuedWorkSpecsArray = allEligibleWorkSpecs.toArray(enqueuedWorkSpecsArray); for (Scheduler scheduler : schedulers) { if (!scheduler.hasLimitedSchedulingSlots()) { scheduler.schedule(enqueuedWorkSpecsArray); } } } }
GreedyScheduler调度器处理任务正在排队,任务最大限制是200条;其它调度器执行在排队且未被调度器处理的,且和现在调度器已经处理的总和不超过配置中的最大限制,默认是版本23是10,其它版本20
3.2.1 GreedyScheduler调度器
类图:
通过流程分析有以下结论:
- 延时通过DelayedWorkTracker来处理,其实是通过Handler机制处理,在主Handler中进行
- 约束通过WorkConstraintsTracker来进行检测,通过自身实现回调WorkConstraintsCallback来处理
- 满足执行条件的任务,通过WorkManagerImpl进行执行
- 实现ExecutionListener接口,来检测已经完成的任务,避免重复执行
- 必须在配置的进程中处理,默认为应用主进程
3.2.2 SystemJobScheduler调度器
类图:
- SystemJobInfoConverter进行数据转换JobScheduler需要的入参,以及其它数据互相转换
- SystemJobService负责调用WorkManagerImpl类方法去执行任务;以及任务执行完成的处理
- 约束由JobScheduler自己内部进行;而这里只需要调用方法即可
这个调度器比较简单,其实现主要通过了JobScheduler的实现,JobScheduler的实现原理这里不做介绍
3.2.3 SystemAlarmScheduler调度器
这个调度器实现就比较复杂了;类图:
- SystemAlarmService服务,任务各种情况通过启动此服务来处理
- SystemAlarmDispatcher, 任务各种情况分发处理节点,并回传无任务处理情况,销毁服务
- CommandHander具体处理各种intent事件,以及内部任务调度完成后收尾(实现了ExecutionListener接口,其意义却不是任务已经执行完成后的处理)
- ConstraintsCommandHandler:处理了约束情况,使用WorkConstraintsTracker检测当时状态;事件是由*Proxy类广播启动service进而通知的, ConstraintProxyUpdateReceiver控制各种约束广播是否可用
- Alarms,使用AlramManager机制处理任务延时,并启动服务
- RescheduleReceiver:开机或者时间事件广播,重启动WorkManager处理剩余任务的;这里只有静态注册,非所有版本有效
- DelayMetCommandHandler:进行约束实时更新判断,并在满足执行时通过WorkManagerImpl执行任务;在类图中没有表明其实现了WorkTimer.TimeLimitExceededListener(这个是对任务从调度状态到运行状态的一个控制,10分钟未完成状态变化重新处理)
- 使用了PowerManager.WakeLock保证调度过程中cpu运行
3.3、任务流程
任务流程涉及调度的具体过程,在以下流程图中就会直接从Schedulers方法直接异步执行启动任务;调度过程省略;
3.3.1 enqueue流程
WorkManager调用时,均是先包装成WorkContinuationImpl然后调用其方法enqueue执行;
WorkContinuationImpl: 包含了一个链表,指向了所有依赖的WorkContinuation对象;也记录了任务本身的一些信息和流程状态
流程图
- WorkerWrapper:handleResult方法后续是对不同结果以及任务类别,对任务数据进行相应处理;其中进行了输入数据的合并;在执行中多个关键点对取消进行校验处理
- WorkForegroundRunnable:启动前台服务,这个需要在实现Work中提供通知信息(方法getForegroundInfoAsync);如果有加急处理,也是启动前台服务
- ListenableWorker中,通过setProgressAsync可以设置进度数据;成功时,doWork返回的结果中含有成功数据
- EnqueueRunnable:enqueueWorkWithPrerequisites方法中对重复任务依据策略进行了处理
- CancelWorkRunable:iterativelyCancelWorkAndDependents删除了依赖其的任务
- 中间大量使用SettableFuture来让各个Runnable执行等待其执行的先决条件
3.3.2 cancel流程
取消时,可以通过任务的id、name、tag或者取消所有;不管是哪种调用,都是通过标识获取满足条件的任务id集合,并进行取消;其流程也只是在id集合查询时不同而已
流程图
- WorkWrapper进行打断时,其流程也就是任务执行时打断处理,在执行过程中多处对打断进行了检测处理
- 取消时,对调度、执行中的任务分别进行取消;同时同步状态到数据库
4、总结
上面只是大概介绍了常规使用,以及相关类图、流程,代码细节并没有展示。代码中的一些技术点需要至少会用
- AlarmMananger
- JobScheduler
- 电量低、内存不足、充电状态、网络状态检测以及实时更新
- SettableFuture
- ......
从代码架构上,也有需要去多思考的地方
- 各个部分的分离抽象
- 广播、服务组件的打开关闭、以及服务的销毁、任务执行完毕后的回调收尾等性能的考量
- 数据库表的设计
- 不论哪个版本,均有两个调度器去执行任务,他们有可能去处理同一个任务,这带来的复杂度处理逻辑以及优势
- ......
以上就是Android WorkManager使用以及源码分析的详细内容,更多关于Android WorkManager的资料请关注脚本之家其它相关文章!
相关文章
Android tabLayout+recyclerView实现锚点定位的示例
这篇文章主要介绍了Android tabLayout+recyclerView实现锚点定位的示例,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧2018-08-08AndroidStudio修改Code Style来格式化自定义标签的xml文件方式
这篇文章主要介绍了AndroidStudio修改Code Style来格式化自定义标签的xml文件方式,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧2020-03-03Android Fragment滑动组件ViewPager的实例详解
这篇文章主要介绍了Android Fragment滑动组件ViewPager的实例详解的相关资料,需要的朋友可以参考下2017-05-05Android UI设计系列之HTML标签实现TextView设置中文字体加粗效果(6)
这篇文章主要介绍了Android UI设计系列之使用HTML标签,实现在TextView中对中文字体加粗的效果,具有一定的实用性和参考价值,感兴趣的小伙伴们可以参考一下2016-06-06
最新评论