Acra详细分析

[b]概述[/b]

Acra是老牌的bug自动采集系统。接入sdk后,可以实现程序崩溃自动发送崩溃日志。
发送自定义的错误日志等功能。具体详细介绍可以参见[url=https://github.com/ACRA/acra]acra官网地址[/url]。
整体来看,Acra就是通过sdk收集进程的崩溃日志,然后以http或mail(默认的两类Sender)的方式将数据发送出去。服务器则是一套基于json的restful的接口。
服务端方面不是本次分析重点,暂不进行分析。
本系列文章将基于Acra 4.9.0 RC2源码进行分析。

[b]Backend[/b]

服务端方面我们需要先搭建一个server,才能更好的看到我们的崩溃信息,
更直观的看到acra给我们提供了哪些针对崩溃的采集内容。
官方提供了acralyzer以及一些针对acra的第三方开源实现。
关于世面上常用的server端的,该文章做了明确分析,[url=https://blog.antoche.com/2013/03/05/crash-reports-and-logs-aggregation-for-android/]针对不同backend的比较[/url]。
官方[url=https://github.com/ACRA/acralyzer]backend acralyzer[/url]的搭建非常简单,具体可以参见该文章的[url=https://inthecheesefactory.com/blog/how-to-install-and-use-acra-android/en]server配置部分[/url]。
项目搭建完成后可以使用通过如下的url对server端进行访问。

查看app崩溃的表结构
http://ip:port/_utils/

[img]http://dl2.iteye.com/upload/attachment/0118/3211/cdeeb46b-22cb-3f5f-99ec-6452acdcec87.png[/img]
查看崩溃日志
http://ip:port/acralyzer/_design/acralyzer/index.html#/dashboard/

[img]http://dl2.iteye.com/upload/attachment/0118/3213/6430d2aa-e403-3429-8ac2-a54bca3e0ab8.png[/img]
关于server端的介绍结束。不是重点。

[b]Client
项目构建[/b]

最新版本项目基于Gradle构建,了解Acra历史的肯定知道该项目是存在了很久了.
Android世界中项目最早是基于ant构建,后来是maven,现在是Gradle。
在没有Gradle的编译环境之前,基本上大部分是基于maven构建。
查看最新版本的代码可以看到仍然包含了之前maven的配置文件。
并且使用Gradle编译编译中使用到的version name等配置参数也都是从pom.xml中读取的。

具体可以参看[url=https://github.com/ACRA/acra/blob/master/build.gradle]build.gradle[/url]中关于版本号的相关配置。
需要注意的是,从github clone下来的项目是无法直接使用Gradle进行编译的。
熟悉Gradle android 编译流程的人应该从build.gradle文件中可以找出错误的原因。
具体的编译文件需要修改的地方为,在build.gradle中开头位置添加编译android项目使用到的plugin。
如下所示:

//此部分添加到build.gradle开头
buildscript {
repositories {
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:2.1.0'
}
}
allprojects {
repositories {
jcenter()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
//此部分添加到build.gradle开头

添加之后,就可以执行gradle build命令打出需要使用的aar包。

[b]项目配置及使用[/b]

首先需要注意一点,Acra使用独立进程:acra,进行采集数据的发送,保证当app崩溃时,采集仍然能发送出去。
由于使用独立的进程,所以会导致application被实例化多次,这样就需要注意app自身的某些业务逻辑,不要在application类中执行多次,从而导致app产生bug。
对Acra的相关配置一般在application中进行初始化。

[b]初始化配置[/b]

在application中进行初始化配置。

使用注解初始化

import org.acra.*;
import org.acra.annotation.*;

@ReportsCrashes(
formUri = "http://www.backendofyourchoice.com/reportpath"
)
public class MyApplication extends Application {
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
// 调用init方法,对acra进行初始化.
ACRA.init(this);
}
}

动态初始化配置

import org.acra.ACRA;
import org.acra.configuration.*;

public class MyApplication extends Application {
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
//使用ConfigurationBuilder构建ACRAConfirueation
final ACRAConfiguration config = new ConfigurationBuilder(this)
.setFoo(foo)
.setBar(bar)
.build();
// 传参的方式初始化acra
ACRA.init(this, config);
}
}

一般使用acra我们的目的是采集崩溃,所以需要在manifest中申请网络权限,以保证crash的正常发送。
<uses-permission android:name="android.permission.INTERNET"/>


[b]目标服务器配置[/b]

acra中发送crash数据是通过Sender实现的,Sender是通过ReportSenderFactory实例化出来的。
而ReportSenderFactory是可以在初始化时进行配置的。
acra默认提供了email及http 两种sender。
如果自定义Sender则需要两个步骤,

[list]
[*]实现ReportSender接口,用来执行发送报告操作。
[*]实现ReportSenderFactory接口,用来创建自定义sender。
[/list]

public class YourOwnSender implements ReportSender {
@Override
public void send(Context context, CrashReportData report) throws ReportSenderException {
// 遍历 CrashReportData 并做发送操作
}
}

public class YourOwnSenderfactory implements ReportSenderFactory {
// 由于在SenderService中通过Class.newInstance()来实例化对象
// 所以需要保证实例化的类的构造函数有一个默认无参的构造函数
// 自定义的ReportSenderFactory必须包含一个不含参数的构造函数
public ReportSender create(Context context, ACRAConfiguration config) {
...
return new YourOwnSender(someConfigPerhaps);
}
}

针对Sender的配置有两种形式,一种为注解,一种为通过代码进行设置。

//注解的方式设置Sender
@ReportCrashes{
reportSenderFactoryClasses = {
your.funky.ReportSenderFactory.class,
other.funky.ReportSenderFactory.class
}
}
public class YourApplication extends Application {
...
}

//代码的方式设置Sender
@ReportCrashes{
...
}
public class YourApplication extends Application {
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);

final Class<? extends ReportSenderFactory>[] myReportSenderFactoryClasses = ...

// 初始化一个ConfigurationBuilder,并设置ReportSenderFactoryClasses.
final ACRAConfiguration config = new ConfigurationBuilder(this)
.setReportSenderFactoryClasses(myReportSenderFactoryClasses)
.build();
ACRA.init(this, config);
}
}

[b]Acra中默认提供两个Sender[/b]

HttpSender

提供了Post及Put两种提交crash到服务器的方式。
提交的类型可以为JSON或Form表单两种方式。

建议使用Put方式进行提交。
Put可以理解成已经知道了某个资源的位置.代表直接更新或创建该资源。
POST为不知道某个资源的位置,由server端来决定对该资源进行何种方式的存储。
所以在此场景下使用Put操作更合适,因为每一条bug实际上就应该对应与数据库中的一条,
只是该条记录还没有上传到服务器。
关于post与put的差别,具体可以查看该文档when should use PUT and when should use POST


EmailIntentSender
组拼crash Report 通过intent调用系统提供的发送email的app。

[b]流程分析及重点类分析
初始化设置流程[/b]

Acra的初始化函数为init,所以使用入口函数ACRA.init()对acra进行初始化。
一般入口函数在application初始化时进行调用。

ACRA.init()

使用ReportsCrashes来初始化Acra。
ACRA提供多个init方法,经过内部调用,最终都会调用参数最多的init方法完成初始化相关逻辑。
下面对重要的init方法进行说明


class ACRA {
//使用Application的注解进行初始化
public static void init(Application app){
//获取application上的注解
final ReportsCrashes reportsCrashes =
app.getClass().getAnnotation(ReportsCrashes.class);
//ConfigurationBuilder中通过注解获取application上配置的注解信息
init(app, new ConfigurationBuilder(app).build());
}
//参数 checkReportsOnApplicationStart 表示
//是否立即执行ErrorReporter.checkReportsOnApplicationStart()方法
public static void init(Application app, ACRAConfiguration config, boolean checkReportsOnApplicationStart){
//根据process的名字判断执行当前方法执行时所在的进程是否是发送crash的进程
final boolean senderServiceProcess = isACRASenderServiceProcess(app);
//ACRA只支持2.3以上的系统版本,所以预先做判断
final boolean supportedAndroidVersion = Build.VERSION.SDK_INT >= Build.VERSION_CODES.FROYO;
//保存config
configProxy = config;
//获取ACRA保存配置的SharedPreferences
final SharedPreferences prefs = new SharedPreferencesFactory(mApplication, configProxy).create();
if (!prefs.getBoolean(PREF__LEGACY_ALREADY_CONVERTED_TO_4_8_0, false)) {
//处理之前的版本的日志文件
}
errorReporterSingleton = new ErrorReporter(mApplication, configProxy, prefs, enableAcra, supportedAndroidVersion, !senderServiceProcess);
//当在非Sender进程,并设置app启动时发送report的情况下进行检测。
//当在Sender进程中,不需要进行检测,因为Sender进程中的逻辑自己会进行判断处理
if (checkReportsOnApplicationStart && !senderServiceProcess) {
//执行发送的相关业务逻辑
final ApplicationStartupProcessor startupProcessor =
new ApplicationStartupProcessor(mApplication, config);
if (config.deleteOldUnsentReportsOnApplicationStart()) {
startupProcessor.deleteUnsentReportsFromOldAppVersion();
}
if (config.deleteUnapprovedReportsOnApplicationStart()) {
startupProcessor.deleteAllUnapprovedReportsBarOne();
}
if (enableAcra) {
startupProcessor.sendApprovedReports();
}
}
}
}

[b]ConfigurationBuilder[/b]

主要用来封装构造ACRAConfiguration的相关属性。
提供了两种方式来设置相关属性的值。

[list]
[*]构造函数通过注解的方式,获取Application中定义注解的值,进行设置。
[*]通过set方法,设置每个不同的配置项。
[/list]

获取属性值之后,通过调用build()方法,创建ACRAConfiguration对象。


//通过app的注解所配置的值对builder对象本身进行初始化
public ConfigurationBuilder(@NonNull Application app)
{
//.....
}
//构建ACRAConfiguration对象
public ACRAConfiguration build() {
return new ACRAConfiguration(this);
}

....
//对外提供的设置相关属性的方法
public ConfigurationBuilder setHttpHeaders(@NonNull Map<String, String> headers) {
this.httpHeaders.clear();
this.httpHeaders.putAll(headers);
return this;
}


[b]ACRAConfiguration[/b]

用来保存ACRA涉及到的所有配置。

[b]SharedPreferencesFactory[/b]

用来获取ACRA所使用的SharedPreferences的文件。
通过这层封装可以对sp进行一些自定义的设置,比如sp的名字。


public class SharedPreferencesFactory {
//获取默认sharedPreferences的流程为
//1.如果通过builder或ReportsCrashes配置所构建的类生成的config文件,
// 包含sp相关配置,则使用该配置项。
//2.如果不满足1的条件,则通过android api PreferenceManager返回默认的sp文件
public SharedPreferences create() {
if (context == null) {
//..
} else if (!"".equals(config.sharedPreferencesName())) {
return context.getSharedPreferences(
config.sharedPreferencesName(), config.sharedPreferencesMode()
);
} else {
return PreferenceManager.getDefaultSharedPreferences(context);
}
}
}


[b]ErrorReporter[/b]

ACRA最核心的类,该类用来捕获crash相关的信息,以及发送crash信息。
Android平台如果想要捕获java层代码的crash需要设置application Thread的UncaughtExceptionHandler。
ACRA会将ErrorReporter设置为Application Thread的UncaughtExceptionHandler。
从而实现对异常的捕获。

这里有一点需要注意的,Thread中的defaultUncaughtHandler为一个对象,
所以多次设置该属性,则会使用最后一个作为异常捕获的类。
比如现在市面上比较火的umeng等相关包含崩溃采集功能sdk。
使用的时候,需要注意查看文档或反编译其源码,查看sdk是怎么实现该部分功能的。
否则容易造成先设置的异常捕获类,无法被执行。



public class ErrorReporter implements Thread.UncaughtExceptionHandler {
ErrorReporter(
@NonNull Application context, @NonNull ACRAConfiguration config,
@NonNull SharedPreferences prefs,boolean enabled,
boolean supportedAndroidVersion, boolean listenForUncaughtExceptions)
{
...
//通过ConfigurationCollector获取系统的相关环境信息
if (config.getReportFields().contains(ReportField.INITIAL_CONFIGURATION)) {
initialConfiguration = ConfigurationCollector.collectConfiguration(this.context);
} else {
initialConfiguration = null;
}
//获取系统时间,崩溃发生时上传
final Calendar appStartDate = new GregorianCalendar();
crashReportDataFactory = new CrashReportDataFactory(
this.context, config, prefs, appStartDate, initialConfiguration);
final Thread.UncaughtExceptionHandler defaultExceptionHandler;
//listenForUncaughtExceptions为Acra初始化流程中传过来的。
//如果当前运行的进程是Sender进程则不监听崩溃。
//如果当前运行的进程是app主进程则对崩溃进行监听
if (listenForUncaughtExceptions) {
defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler();
Thread.setDefaultUncaughtExceptionHandler(this);
} else {
defaultExceptionHandler = null;
}
//记录最后的activity
final LastActivityManager lastActivityManager = new LastActivityManager(this.context);
//用来保存针对崩溃的一些用户自定义的信息
final ReportPrimer reportPrimer = getReportPrimer(config);

reportExecutor = new ReportExecutor(
context, config, crashReportDataFactory,
lastActivityManager, defaultExceptionHandler, reportPrimer);
reportExecutor.setEnabled(enabled);
}

//崩溃采集需要实现UncaughtExceptionHandler为接口。
@Override
public void uncaughtException(@Nullable Thread t, @NonNull Throwable e) {
//未开启crash采集时,使用之前默认的ExceptionHandler处理
if (!reportExecutor.isEnabled()) {
reportExecutor.handReportToDefaultExceptionHandler(t, e);
return;
}
try {
ACRA.log.e(LOG_TAG, "ACRA caught a " + e.getClass().getSimpleName() +
" for " + context.getPackageName(), e);
if (ACRA.DEV_LOGGING) ACRA.log.d(LOG_TAG, "Building report");
performDeprecatedReportPriming();
// 生成并发送report
new ReportBuilder()
.uncaughtExceptionThread(t)
.exception(e)
.endApplication()
.build(reportExecutor);
} catch (Throwable fatality) {
// ACRA failed. Prevent any recursive call to ACRA.uncaughtException(), let the native reporter do its job.
ACRA.log.e(LOG_TAG, "ACRA failed to capture the error - handing off to native error reporter" , fatality);
reportExecutor.handReportToDefaultExceptionHandler(t, e);
}
}

}

参见代码可以知道,acra通过设置默认ExceptionHandler来捕获异常。
并把自己设置为处理对象。

[b]LastActivityManager[/b]

是用来记录最后展示的Activity的,通过application.registerActivityLifecycleCallbacks来实现记录功能的。ACRA可以在崩溃的时候弹出Dialog,所以需要记住最后的Activity。

[b]ReportExecutor[/b]

主要业务逻辑关注execute()方法.
该类主要负责调用CrashReportDataFactory采集数据,
调用CrashReportPersister对崩溃数据进行持久化,
调用SenderServiceStarter运行Service发送的报告。

[b]ApplicationStartupProcessor[/b]

封装一些App启动时可能执行的任务


class ApplicationStartupProcessor{
void deleteUnsentReportsFromOldAppVersion(){
//app版本更新后,一般会修掉老的崩溃等问题,
//所以当老版本更新到新版本后,可以将老版本记录的日志全部删除掉
}

void deleteAllUnapprovedReportsBarOne(){
//unapproved的文件夹内的文件,只保留最新创建的日志文件,其他的全部删除掉。
}

void sendApprovedReports(){
//调用SenderServiceStarter开启Service进行崩溃日志的发送。
}

}

[b]ReportLocator[/b]

关于ACRA对日志文件位置的处理主要是ReportLocator来设置的。
acra内部使用文件对崩溃日志进行保存,该类用来获取文件夹的名字。
内部有两个文件夹acra-unapproved(未处理),acra-approved(处理过)分别用来保存未处理及处理过的崩溃文件。

[b]采集内容[/b]

崩溃采集,必然需要采集崩溃及手机的相关信息。
ACRA中涉及到崩溃相关信息的主要有如下一些类。
ReportBuilder,ReportPrimer,CrashReportDataFactory,CrashReportData,
LogCatCollector,DropBoxCollector,ReportUtils,UUID,
Installation,ConfigurationCollector,DumpSysCollector,ReflectionCollector,
DisplayManagerCollector,DeviceFeaturesCollector,settingsCollector,
LogFileCollector,MediaCodecListCollector,ThreadCollector.
ACRA获取全部数据,涉及到的类比较多。下面逐个分析。

[b]ReportBuilder[/b]

对throwable,message,自定义信息,以及exception的简单封装。
主要方法为build(),通过build方法调用ReportExecutor.execute()方法,
在ReportExcutor中进行真正的crash采集以及调用发送Service

[b]ReportPrimer[/b]

用来设置崩溃时候,用户需要保存的一些用户自定义的信息。
比如崩溃时候在此类中设置一些用户账号等相关信息。
该类中设置的相关内容会一起发送到服务端,从而更好的定位一些崩溃信息。

[b]CrashReportDataFactory,CrashReportData[/b]

CrashReportDataFactory类用来实例化CrashReportData。
其中最重要的方法为createCrashData()方法,使用该方法来组拼CrashReportData。
CrashReportData继承EnumMap,其中保存的数据的key为各种上传时候的key,
对应的值为崩溃的相关信息。后面的流程中该类中的值会通过CrashReportPersister类写入file文件。

[b]LogCatCollector[/b]

用来获取logcat日志中的相关信息,执行Logcat命令,读取命令输出信息。


class LogCatCollector{
public String collectLogCat(){
//根据所传参数不同组拼不同的logcat命令
//主要组拼出的命令为
//1.logcat -t 100 -v time
//2.logcat -t 100 -v time -b radio
//1.logcat -t 100 -v time -b events
}
}



logcat -b events

05-18 19:45:46.158 31191 31191 I auditd : type=1400 audit(0.0:505001): avc: denied { search } for comm="PerfFgMonitor" name="1711" dev="proc" ino=18618 scontext=u:r:untrusted_app:s0:c512,c768 tcontext=u:r:radio:s0 tclass=dir permissive=0

logcat -b radio

05-18 19:44:39.343 1711 1785 D RILJ : [9679]< RIL_REQUEST_GET_CELL_INFO_LIST [CellInfoWcdma:{mRegistered=YES mTimeStampType=oem_ril mTimeStamp=1283159923921792ns CellIdentityWcdma:{ mMcc=460 mMnc=1 mLac=53529 mCid=101852154 mPsc=438} CellSignalStrengthWcdma: ss=8 ber=99}] [SUB0]
05-18 19:44:39.345 1711 1975 D GsmSST : [GsmSST] SST.getAllCellInfo(): X size=1 list=[CellInfoWcdma:{mRegistered=YES mTimeStampType=oem_ril mTimeStamp=1283159923921792ns CellIdentityWcdma:{ mMcc=460 mMnc=1 mLac=53529 mCid=101852154 mPsc=438} CellSignalStrengthWcdma: ss=8 ber=99}]
05-18 19:44:39.346 1711 1975 D GsmSST : [GsmSST] getCellLocation(): X ret WCDMA info=[53529,101852154,438]
05-18 19:44:43.068 1711 1927 D SubscriptionController: [getPhoneId]- no sims, returning default phoneId=2147483647

其实相信大部分人不太清楚logcat的相关命令。
针对以上的三条命令做如下解释

logcat -t 100 -v time
-t 限制打印100行内容
-v time 设置日志输出格式。打印日志的为:打印日期->触发时间->优先级(E,W,V)->tag->出问题进程的pid
关于日志输出格式的介绍参见此处日志输出格式。

logcat -b [options] 切换打印log的内容级别

radio radio/telephony相关log
events events-related相关log
main 默认的log


[b]DropBoxCollector[/b]

通过DropBoxManager读取系统系统的日志信息
DropBoxManager,很多人应该也没接触过。
android系统实际上是有三种日志打印的。log EventLog DropBox,关于三种log的介绍参见此处。
[url=http://stackoverflow.com/questions/4434192/dropboxmanager-use-cases]三种log的介绍[/url]
关于DropBoxManager的相关内容可以参见此处。[url=http://xiaocong.github.io/blog/2012/11/21/to-introduce-android-dropboxmanager-service/]dropboxManager介绍[/url]

class DropBoxCollector{
public String read(){
//通过DropBoxService获取系统的DropBoxManager
//读取所有预先定义的不同tag对应的日志内容
}
}

[b]ReportUtils[/b]
封装的各种工具类,用来获取系统相关的信息

public getAvailableInternalMemorySize(){
//通过StatFs类获取可用内存block数量及每个block的size
//block_size * free_block_count = 可用内存数
}

public getTotalInternalMemorySize(){
//通过StatFs类获取所有内存block数量及每个block的size
//block_size * total_block_count = 总内存数
}

public getDeviceId(){
//通过TelephonyManager获取deviceId
//GSM手机对应与IMEI
//CDMA手机对应与ESN或MEID
}

public getApplicationFilePath(){
//通过context.getFilesDir()获取当前app的绝对路径
//'/data/user/0/yftx.net.oschina.git.gradlesample/files'
}

public getLocalIpAddress(){
//通过NetworkInterface 获取当前设备的ip
}

public getTimeString(){
//通过Calendar类获取当前时间
}

UUID
java.util包中提供的类,用来生成唯一字符串的类。

Installation
用来生成唯一身份串的类。


class Installation{
void id(){
//获取的id用来标记用户的身份。
//具体算法可以参见android blog中的解释。
//http://android-developers.blogspot.com/2011/03/identifying-app-installations.html
}
}

ConfigurationCollector
通过反射系统的Configuration类,获取系统相关参数。


class ConfigurationCollector{
void collectConfiguration(Context context){
//通过 context.getResources().getConfiguration()获取configration对象,
//并用反射获取该类中的相关信息
}
}


DumpSysCollector
通过执行dumpsys meminfo xxxpid 来分析内存
关于dumpsys的介绍参见此:[url=https://developer.android.com/studio/profile/investigate-ram.html]dumsys相关介绍[/url]

class DumpSysCollector{
void collectMemInfo(){
//执行dumsys 相关命令
}
}

ReflectionCollector
相当于Util类,主要通过反射获取传过来的类的一些信息。

class ReflectionCollector{
void collectConstants(){
//通过反射获取系统的相关信息
//acra中主要获取Build,Build.Version中的相关数据
}
}

DisplayManagerCollector
主要用来获取手机显示相关的数据

class DisplayManagerCollector{
void collectDisplays(){
//通过Display类获取屏幕宽,高,方向等显示相关的参数
}
}

DeviceFeaturesCollector
通过PackageManager获取系统相关特性。比如glEsVersion等

class DeviceFeaturesCollector{
void getFeatures(){
//通过PackageManager获取系统相关特性。比如glEsVersion等
}
}

SettingsCollector
使用反射获取android.provider.Settings.x中的相关内容。

class SettingsCollector{
void collectSystemSettings(){
//获取系统Settings类中的相关信息
}

void collectSecureSettings(){
//获取Settings.Secure中的相关信息
}

void collectGlobalSettings(){
//获取Settings.Global中的相关信息
}
}

LogFileCollector
获取用户自己保存的相关的log文件,使用该接口可以让acra结合[url=https://github.com/tony19/logback-android]logback-android[/url]这类类库相结合。
很多做android的同学都没有做过java web开发,并且android的Log接口也还算好用,再加上客户端编程和服务端编程系统的不同,所以可能理解不了logback-android这样库的意义。
实际上logback-android这类库主要就是可以指定log输出的位置,以及log的打印级别。
关于java开发中log的重要性可以参见此文章,[url=http://javarevisited.blogspot.com/2011/05/top-10-tips-on-logging-in-java.html]java log的意义[/url]

MediaCodecListCollector
主要用来获取系统支持哪些音视频类型等媒体相关的。

ThreadCollector
获取崩溃线程的相关信息。

class ThreadCollector{
void collect(Thread t){
//获取线程t的相关信息,id,name,priority,groupName
}
}

ACRA中用到的其他一些获取异常的方法

getStackTracehash(Throwable th){
//通过组拼Error的className及MethodName生成的字符串
//获取该字符串的hash值
//服务端可以根据该值做崩溃分类
}

结语
本部分内容主要包括

[list]
[*]ACRA如何配置(服务端,客户端的配置)
[*]崩溃信息相关内容如何采集,涉及到的关键类。
[/list]
后面的部分会继续分析如何将生成的file发送到服务端。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值