Android 性能优化之旅5--电量优化

小楠总 · · 2909 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

一、前言

当手机运行的时候,处理各种任务,硬件就会消耗电量。用户可以通过手机自带的电量监测功能来查看电量使用情况:

电量使用情况(小米).png
电量使用情况(小米).png

一旦用户发现你的APP耗电量特别大,那可就麻烦了。因此我们需要在设计app的时候,就应该下功夫,充分考虑电量优化的问题。

二、电量消耗理论与分析

写出耗电量低的应用的关键是要透彻理解它的理论以及全部过程。下面将对电量消耗的相关理论知识进行介绍。

1、电量消耗的概念

首先要知道,电量的消耗,主要是指硬件的电量消耗(废话),在电子世界,这种硬件消耗电量来执行任务的过程,叫做超时电流消耗。

硬件消耗电量.png
硬件消耗电量.png

不同情况下,相同时间内,消耗的电量是不同的。比如使用飞行模式待机,确实可以坚持10多天。但是我们一旦使用手机,比如使用蜂窝式无线数据交换(3G4G)、屏幕保持唤醒状态等,电量就会消耗得很快:

耗电分析.png
耗电分析.png

作为开发者,我们很想知道我的应用执行的哪些任务消耗的电量是最多的?这个问题确实会很棘手。

电量优化是方方面面的,比如说减少内存的开销,减少界面的过度绘制,本身就是一种电量优化。

2、电量消耗计算

电量消耗的计算与统计是一件麻烦而且矛盾的事情,记录电量消耗本身也是一个费电量的事情(所以很多手机的定制系统都把这个监测电量的功能阉割掉了)。

唯一可行的方案是使用第三方监测电量的设备,这样才能够获取到真实的电量消耗(因为第三方硬件监测的时候是用的自己的供电而不是用的手机的电量)。比如使用功耗仪。

打开屏幕,所有要使用CPU/GPU工作的动作都会唤醒屏幕,都会消耗电量。这和应用程序代码唤醒设备还不一样。比如使用叫醒闹钟(wake clock)、AlarmManager、JobSchedulerAPI。因此很难知道自己的应用程序的真实耗电情况。

3、设备待机与唤醒电量消耗分析

为什么要单独拿这个出来讲呢,就是因为,唤醒这个瞬间是非常耗电的,下面允许我慢慢介绍。

先来看看待机状态的电量消耗:

待机状态电量消耗.png
待机状态电量消耗.png

待机状态下,电量的消耗是非常少的,这是毋庸置疑的。

使用和唤醒屏幕后:

屏幕唤醒.png
屏幕唤醒.png

可以看到,屏幕唤醒的一瞬间是非常耗电的,这里有一条电量使用高峰线。

下面来看看CPU唤醒的曲线(CPU唤醒,屏幕不一定会唤醒):

CPU唤醒.png
CPU唤醒.png

同样的,CPU唤醒的时候也会有一条电量使用高峰线。

CPU唤醒之后:

唤醒之后.png
唤醒之后.png

CPU唤醒之后,设备的耗电不会出现唤醒的时候的高峰线。

值得注意的是当工作完成后,设备会主动进行休眠,这非常重要,在不使用或者很少使用的情况下,长时间保持屏幕唤醒会迅速消耗电池的电量。

结论

设备唤醒的瞬间是有消耗高峰的,因此,当你的工作需要持续的时候,可以考虑保持唤醒状态。

4、无线蜂窝耗电分析

蜂窝式无线也是耗电量非常可怕的,甚至比WIFI更加耗电,因此这里单独拿出来进行分析。

Tips:不使用流量的时候,最好把数据关闭,这样又省电又省流量。

下面开始分析无线蜂窝耗电的过程:

无线蜂窝耗电过程.png
无线蜂窝耗电过程.png

如上图所示:

  1. 当设备通过无线网发送数据的时候,为了使用硬件,这里会出现一个唤醒高峰。
  2. 接下来还有一个高数值,这是发送数据包消耗的电量。
  3. 然后接受数据包也会消耗大量电量,也看到一个峰值。
  4. 保持唤醒状态,耗电比较均衡,很少出现高峰点。

所以我们开启无线模式这个过程非常耗电,那么硬件这块为了防止频繁开启关闭耗电,采取了一个无奈的办法,会在一个小段时间内保持开启模式,防止短时间内还有数据包需要接收。这些数据非常有用,可是不是所有开发者都有这个第三方设备跟踪。但是使用Android L版本就可以利用到新的一系列的工具来优化应用程序的耗电。(这里显然不要考虑兼容性问题,我只是想测电量消耗问题,同一款APP在不同版本的Android上耗电情况应该不会有太大影响,虽然不同Android版本对电量的优化不同,但是我们的分析对象是我们自己的APP本身)

三、电量分析工具Battery Historian的环境搭建与使用

Battery Historian(https://github.com/google/battery-historian)是一款电量使用记录分析工具。通过ADB获取的数据,使用Battery Historian工具分析处理后,得到的html结果文件,用浏览器可以直接查看、分析的。

1、环境搭建

有两种方式,通过Docker或者通过编译源码的方式来安装。

通过Docker安装(推荐在MAC或者Linux上面使用)

Docker是一种容器,一般用于云计算和大数据平台。提倡的一种思想就是:软件即服务。这句话不是盖的,一句话就可以将别人发布的docker服务环境一次全部copy过来(注意是整个软件环境,相当于复制了一台一模一样的主机,连软件都不要安装了,全有了。)
但是,对于Windows用户来说,Docker只支持Windows10。一般推荐在MAC或者Linux上面使用。

开启这个Docker服务的命令如下:

docker -- run -p <port>:9999 gcr.io/android-battery-historian/stable:3.0 --port 9999

上面这种是临时开辟的程序服务,也可以加上-d参数,开启一个单独系统服务更正规的服务。

通过编译源码的方式安装(推荐Windows上面使用)
  • 安装GO环境

到官网下载GO安装包:https://golang.org/dl/,这里给出1.9版本的下载路径https://golang.org/doc/install?download=go1.9.windows-amd64.msi

配置环境变量:

  1. GOROOT: GOROOT的作用是告诉Go 命令和其他相关工具,在哪里去找到安装在你系统上的Go包,所以这里配置的是GO的安装目录。
  2. GOPATH:GOPATH可以简单理解为是工程的目录,所以需要手动创建一个GO的工程路径。
  3. PATH:把Go的bin目录放到PATH环境变量中。

安装完之后,输入下面的命令检查GO环境是否正确安装:

go version
  • 安装Git

这里参考廖雪峰的博客。

  • 安装Python2.7(注意不是Python3)

这里参考廖雪峰的博客,注意添加Python到PATH中。

  • 安装Java环境

这个没什么好说的。

  • 下载源码并运行

通过下面的GO命令下载Battery Historian源码并且编译运行:

go get -d -u github.com/google/battery-historian/...
Tips:下载到GOPATH配置目录下。

切换目录:

cd $GOPATH/src/github.com/google/battery-historian

执行配置脚本(编译),注意这个过程可能需要翻墙:

go run setup.go

启动battery historian:

go run cmd/battery-historian/battery-historian.go

这时候,打开浏览器,输入http://127.0.0.1:9999/,显示如下页面代表安装成功(需要访问Google的静态资源,需要翻墙):

环境搭建好实例.png
环境搭建好实例.png

2、数据采集

  • 初始化

battery-historian工具需要使用bugreport中的Battery History,因此需要如下的操作。

重启adb服务:

adb kill-server
adb start-server

这一步很重要,因为当我们开发时做电量记录时会打开很多可能造成冲突的东西。为了保险起见我们重启adb。

通过以下命令来打开电池数据的获取以及重置:

adb shell dumpsys batterystats --enable full-wake-history
adb shell dumpsys batterystats --reset

上面的操作很重要,因为可以过滤掉不需要的数据。然后断开数据线(防止数据线造成充放电数据干扰),运行自己的APP进行测试。

  • 获取数据

重新连接USB调试,通过下面的命令获取数据:

adb bugreport bugreport.zip(6.0以及以下的,使用txt)
注意:7.0以下的,需要使用旧版本的adb工具,不然没法采集,参考文章http://blog.csdn.net/mwq30123/article/details/53888449
注意:官方SDK文档导出文件方式为:adb shell dumpsys batterystats > batterystats.txt。使用python historian.py batterystats.txt > batterystats.html查看数据。这是battery-historian老版本的使用方式. 目前Battery Historian已更新2.0版本, 推荐使用bugreport方式导出数据分析, 可以看到更多信息。
注意:模拟器可能获取不到有用的电量数据,网页没有显示电量信息,如下图所示:(你可能需要一部实体手机)
电量分析.png
电量分析.png
  • 上传数据,进行分析

最后一步是打开http://127.0.0.1:9999/,提交数据进行测试。

电量分析(基于V2.0版本的battery historian)

上面一路过来也是不容易啊,笔者也是踩了不少的坑才过来的,所以有什么问题尽管在我的博客下面留言,我尽量一一解答。

新版本的电量分析界面如下图所示:

电量分析界面.png
电量分析界面.png

几个我们需要关心的重要参数说明:

  • CPU running:CPU的运行状态,是否被唤醒。如果把鼠标放到上面去,还能看到更多的信息,如CPU唤醒的原因。
  • Wakelock:唤醒锁
  • Screen:屏幕是否开启
  • Top APP:当前最上层的APP
  • Mobile network type:网络类型,其中需要注意的是,“免费网络可能包括wifi、蓝牙网络共享、USB网络共享”
  • Mobile radio active:移动蜂窝信号
  • WiFi supplicant:wifi是否开启
  • WiFi signal strength:wifi强度
  • Audio:音频是否开启
  • Battery Level:电量
  • Plugged:是否正在充电,以及鼠标放在上面的时候可以看到充电类型,包括AC(充电器)、USB、其它(例如无线充电)
  • 底部的横坐标是时间

分析的建议:

  • 发现很密集的,不断唤醒CPU的时候,就可能需要进行优化了(唤醒锁)
  • 某些服务是否在充上电以后才执行?
  • 网络的使用与电量等等
  • 屏幕是否一直常亮?

四、电量优化建议

当Android设备空闲时,屏幕会变暗,然后关闭屏幕,最后会停止CPU的运行,这样可以防止电池电量掉的快。在休眠过程中自定义的Timer、Handler、Thread、Service等都会暂停。但有些时候我们需要改变Android系统默认的这种状态:比如玩游戏时我们需要保持屏幕常亮,比如一些下载操作不需要屏幕常亮但需要CPU一直运行直到任务完成。从而防止因为唤醒的瞬间而耗更多的电。

1、判断充电状态

为了省电,有些工作(不需要及时地和用户交互的操作)可以放当手机插上电源的时候去做。比如手机助手类的项目,自动清理手机垃圾,自动备份上传图片、联系人等到云端等代码,可以等待用户充电以及有网络的时候再执行,判断是否充电的代码如下:

private boolean checkForPower() {
    //获取电池的充电状态(注册一个广播)
    IntentFilter filter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
    Intent res = this.registerReceiver(null, filter);

    //通过使用BatteryManager的参数信息判断充电状态
    if (res != null) {
        int chargePlug = res.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1);
        boolean usb = chargePlug == BatteryManager.BATTERY_PLUGGED_USB;//usb充电
        boolean ac = chargePlug == BatteryManager.BATTERY_PLUGGED_AC;//交流电
        //无线充电,这个需要API>=17
        boolean wireless = false;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
            wireless = chargePlug == BatteryManager.BATTERY_PLUGGED_WIRELESS;
        }
        return (usb || ac || wireless);
    } else {
        return false;
    }
}

判断网络是否连接的代码如下:

private boolean isNetWorkConnected() {
    //判断网络连接
    ConnectivityManager connectivityManager = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
    NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo();
    return (activeNetworkInfo != null && activeNetworkInfo.isConnected());
}

需要添加权限:

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>

2、屏幕保持常亮

为了防止屏幕唤醒一瞬间耗电过多,有一些应用,比如游戏、支付页面,需要保持屏幕常亮来节省电量:

getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);

也可以在布局文件里面使用,但是没有那么灵活:

android:keepScreenOn="true"
注意:一般不需要人为的去掉FLAG_KEEP_SCREEN_ON的flag,windowManager会管理好程序进入后台回到前台的的操作。如果确实需要手动清掉常亮的flag,使用getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)

3.1、使用wake_lock

系统为了节省电量,CPU在没有任务忙的时候就会自动进入休眠。有任务需要唤醒CPU高效执行的时候,就会给CPU加wake_lock锁。wake_lock锁主要是相对系统的休眠而言的,意思就是我的程序给CPU加了这个锁那系统就不会休眠了,这样做的目的是为了全力配合我们程序的运行。有的情况如果不这么做就会出现一些问题,比如微信等及时通讯的心跳包会在熄屏不久后停止网络访问等问题。所以微信里面是有大量使用到了wake_lock锁。

PowerManager这个系统服务的唤醒锁(wake locks)特征来保持CPU处于唤醒状态。唤醒锁允许程序控制宿主设备的电量状态。创建和持有唤醒锁对电池的续航有较大的影响,所以,除非是真的需要唤醒锁完成尽可能短的时间在后台完成的任务时才使用它。比如在Acitivity中就没必要用了。一种典型的代表就是在屏幕关闭以后,后台服务继续保持CPU运行。

如果不使用唤醒锁来执行后台服务,不能保证因CPU休眠未来的某个时刻任务会停止,这不是我们想要的。(有的人可能认为以前写的后台服务就没掉过链子呀运行得挺好的,1.可能是你的任务时间比较短;2.可能CPU被手机里面很多其他的软件一直在唤醒状态)。

其中,唤醒锁有下面几种类型:

唤醒锁的类型.png
唤醒锁的类型.png

wake_lock两种锁(从释放、使用的角度来看的话):

  • 一种计数锁
  • 非计数锁(锁了很多次,只需要release一次就可以解除了)
Tips:请注意,自 API 等级17开始,FULL_WAKE_LOCK将被弃用,应使用FLAG_KEEP_SCREEN_ON代替。

综上所述,为了防止CPU唤醒一瞬间耗电过多,在执行关键代码的时候,为了防止CPU睡眠,需要使用唤醒锁来节省电量:

//创建唤醒锁
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
PowerManager.WakeLock wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "partial_lock");

//获取唤醒锁
wakeLock.acquire();

//一些关键的代码

//释放唤醒锁
wakeLock.release();

需要添加权限:

<uses-permission android:name="android.permission.WAKE_LOCK"/>
Tips:获取与释放唤醒锁需要成对出现
Tips:有一些意外的情况,比如小米手机是做了同步心跳包(心跳对齐)(如果超过了这个同步的频率就会被屏蔽掉或者降频),所有的app后台唤醒频率不能太高,这时候就需要降频,比如每隔2S中去请求。

3.2、使用WakefulBroadcastReceiver

上面提到,典型的使用场景就是后台服务需要保持CPU保持运行,推荐的方式是使用WakefulBroadcastReceiver:使用广播和Service(典型的IntentService)结合的方式可以让你很好地管理后台服务的生命周期。

WakefulBroadcastReceiver是BroadcastReceiver的一种特例。它会为你的APP创建和管理一个PARTIAL_WAKE_LOCK类型的WakeLock。WakefulBroadcastReceiver把工作交接给service(通常是IntentService),并保证交接过程中设备不会进入休眠状态。如果不持有WakeLock,设备很容易在任务未执行完前休眠。最终结果是你的应用不知道会在什么时候能把工作完成,相信这不是你想要的。

例子:

服务:

public class MyIntentService extends IntentService {

    public MyIntentService(String name) {
        super(name);
    }

    public MyIntentService() {
        super(MyIntentService.class.getSimpleName());
    }

    @Override
    protected void onHandleIntent(@Nullable Intent intent) {
        if (intent != null) {
            //获取参数
            Bundle extras = intent.getExtras();

            //执行一些需要CPU保持唤醒的代码

            //执行结束,释放唤醒锁
            MyWakefulReceiver.completeWakefulIntent(intent);
        }
    }
}

广播接收者:

public class MyWakefulReceiver extends WakefulBroadcastReceiver {

    @Override
    public void onReceive(Context context, Intent intent) {
        Intent service = new Intent(context, MyIntentService.class);
        startWakefulService(context, service);
    }

}

需要使用服务的时候,像一般的方式一样即可:

Intent intent = new Intent(this, MyIntentService.class);
//传递参数
intent.setData(Uri.parse("xxx"));
Tips:注意添加权限
Tips:注意服务与广播的注册
Tips:使用广播来设计,就是为了解耦

3.3、大量高频次的CPU唤醒及操作使用JobScheduler/GCM

大量高频次的CPU唤醒及操作,我们可以采取一些算法来解决,把这些操作安排在一个时间点集中处理,而不是分开处理(这样就可以防止了唤醒的耗电)。

我们可以使用谷歌提供的JobScheduler或者GCM来实现这样的功能。

下面举一个频繁请求网络的例子:

这是一个请求网络的服务:

public class MyJobService extends JobService {
    private static final String TAG = "MyJobService";

    @Override
    public void onCreate() {
        super.onCreate();
        Log.i(TAG, "MyJobService created");
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        Log.i(TAG, "MyJobService destroyed");
    }

    /**
     * 开启耗时操作
     * @param params
     * @return
     */
    @Override
    public boolean onStartJob(JobParameters params) {
        Log.i(TAG, "onStartJob:" + params.getJobId());
        if (isNetworkConnected()) {
            new SimpleDownloadTask() .execute(params);
            return true;
        } else {
            Log.i(TAG, "No connection:" + params.getJobId());
        }
        return false;
    }

    /**
     * jobFinish调用之前会回调
     * @param params
     * @return
     */
    @Override
    public boolean onStopJob(JobParameters params) {
        Log.i(TAG, "onStopJob:" + params.getJobId());
        return false;
    }

    private boolean isNetworkConnected() {
        ConnectivityManager connectivityManager =
                (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
        NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo();
        return (networkInfo != null && networkInfo.isConnected());
    }

    private class SimpleDownloadTask extends AsyncTask<JobParameters, Void, String> {

        protected JobParameters mJobParam;

        @Override
        protected String doInBackground(JobParameters... params) {
            mJobParam = params[0];
            try {
                InputStream is = null;
                int len = 50;

                URL url = new URL("https://www.baidu.com");
                HttpURLConnection conn = (HttpURLConnection) url.openConnection();
                conn.setReadTimeout(10000); //10sec
                conn.setConnectTimeout(15000); //15sec
                conn.setRequestMethod("GET");

                conn.connect();
                int response = conn.getResponseCode();
                Log.d(TAG, "The response is: " + response);
                is = conn.getInputStream();

                Reader reader = new InputStreamReader(is, "UTF-8");
                char[] buffer = new char[len];
                reader.read(buffer);
                return new String(buffer);

            } catch (IOException e) {
                e.printStackTrace();
                return null;
            }
        }

        @Override
        protected void onPostExecute(String result) {
            //结束任务
            jobFinished(mJobParam, false);
            Log.i(TAG, result);
        }
    }
}

下面通过循环来模拟频繁调用:

ComponentName serviceComponent = new ComponentName(this,MyJobService.class);
//频繁地唤醒
JobScheduler jobScheduler = (JobScheduler) getSystemService(Context.JOB_SCHEDULER_SERVICE);
for (int i = 0; i < 500; i++) {
    JobInfo jobInfo = new JobInfo.Builder(i,serviceComponent)
            .setMinimumLatency(5000)//最小延时5秒
            .setOverrideDeadline(60000)//最多执行时间60秒
            //.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED)//免费的网络---wifi 蓝牙 USB
            .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)//任意网络---wifi
            .build();
    jobScheduler.schedule(jobInfo);
}

4、使用AlarmManager来唤醒

当机器一段时间不操作以后,就会进入睡眠状态。向服务器的轮询就会停止、长连接就会断开,为了防止这样的情况,就可以使用AlarmManager:

Intent intent = new Intent(this, TestService.class);
PendingIntent pi = PendingIntent.getService(this, 0, intent, 0);

AlarmManager am = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
am.cancel(pi);

//闹钟在系统睡眠状态下会唤醒系统并执行提示功能
//模糊时间,在API-19中以及以前,setRepeating都是不准确的
am.setRepeating(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + 1000, 2000, pi);
//准确时间,但是需要在API-17之后使用
am.setExact(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + 1000, pi);
am.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + 1000, pi);

该定时器可以启动Service服务、发送广播、跳转Activity,并且会在系统睡眠状态下唤醒系统。所以该方法不用获取电源锁和释放电源锁。

关于AlarmManager的更多信息,请参考其他文章。

在19以上版本,setRepeating中设置的频率只是建议值(6.0 的源码中最小值是60s),如果要精确一些的用setWindow或者setExact。

5、其他优化

当然,电量优化是包括很多方面的,例如:

  • 渲染优化
  • 定位策略优化
  • 网络优化,例如网络缓存处理,请求方式、次数优化、设置超时时间等等
  • 代码执行效率优化
  • 防止内存泄漏

等等,电量优化无处不在。

深化

首先Android手机有两个处理器,一个叫Application Processor(AP),一个叫Baseband Processor(BP)。AP是ARM架构的处理器,用于运行Linux+Android系统;BP用于运行实时操作系统(RTOS),通讯协议栈运行于BP的RTOS之上。非通话时间,BP的能耗基本上在5mA左右,而AP只要处于非休眠状态,能耗至少在50mA以上,执行图形运算时会更高。另外LCD工作时功耗在100mA左右,WIFI也在100mA左右。一般手机待机时,AP、LCD、WIFI均进入休眠状态,这时Android中应用程序的代码也会停止执行。

  • Android为了确保应用程序中关键代码的正确执行,提供了Wake Lock的API,使得应用程序有权限通过代码阻止AP进入休眠状态。但如果不领会Android设计者的意图而滥用Wake Lock API,为了自身程序在后台的正常工作而长时间阻止AP进入休眠状态,就会成为待机电池杀手。比如前段时间的某应用,比如现在仍然干着这事的某应用。
  • AlarmManager 是Android 系统封装的用于管理 RTC 的模块,RTC (Real Time Clock) 是一个独立的硬件时钟,可以在 CPU 休眠时正常运行,在预设的时间到达时,通过中断唤醒 CPU。(极光推送就是利用这个来做的。)

总结:

  1. 关键逻辑的执行过程,就需要Wake Lock来保护。如断线重连重新登陆
  2. 休眠的情况下如何唤醒来执行任务?用AlarmManager。如推送消息的获取

最后,通过Wakelock Detector(WLD)软件可以看到手机中的Wakelock:

WLD.png
WLD.png

参考文章:

http://www.jianshu.com/p/ded0ed6fac3d
http://www.jianshu.com/p/5b8bfa6a6c37
http://www.jianshu.com/p/fc2a4d191e18


有疑问加站长微信联系(非本文作者)

本文来自:简书

感谢作者:小楠总

查看原文:Android 性能优化之旅5--电量优化

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

2909 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传