android圖片壓縮上傳系列-service篇

本篇文章是繼續上篇android圖片壓縮上傳系列-基礎篇文章的續篇。主要目的是:通過Service來執行圖片壓縮任務來討論如何使用Service,如何處理任務量大的并發問題。

了解下Service

大家都知道如果有費時任務,這時需要將任務放到后臺線程中執行,如果對操作的結果需要通過ui展示還需要在任務完成后通知前臺更新。當然對于這種情況,大家也可以在Activity中啟動線程,在線程中通過Handler和sendMessage來通知Activity并執行更新ui的操作,但是更好的方法是將這些操作放到單獨的Service中。由于Activity生命周期的復雜性會導致管理線程的復雜度過高,而Service的生命周期相比Activity來說就只有創建和銷毀,更有利于執行管理耗時操作。

  • 何時使用Service
    android文檔官方解釋:Service表示不在影響用戶操作的情況下執行耗時的操作或提供供其它應用使用的功能。
  • Service類型
  1. 用來執行和用戶輸入無關的操作,比如音樂播放器,用戶退出應用的情況下還能執行播放操作
  2. 由用戶觸發的操作,如上傳圖片(在后臺執行上傳,完畢后停止Service)
  • Service生命周期
    簡單講只有兩個必定被調用的回調函數,分別是onCreate(初始化),和onDestroy(清理)
  • 啟動Service
    可以通過兩種方式啟動:Context.startService()Context.bindService()
    1. Context.startService()
      Context.startService()啟動Service時,Service的onStartCommand()方法會被調用,并且在Service沒有銷毀前,不管前臺執行多少次startService()操作,Service的onCreate只執行一遍,而onStartCommand()方法將被執行多遍。大家可以做個簡單測試如下:
public class LGImgCompressorService extends Service {
    private static final String TAG = "LGImgCompressorService";

    public LGImgCompressorService() {
    }

    @Override
    public void onCreate() {
        super.onCreate();
        Log.d(TAG,"onCreate... thread id:" + Thread.currentThread().getId());
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        Log.d(TAG,"onDestroy...thread id:" + Thread.currentThread().getId());
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        Log.d(TAG,"onStartCommand thread id:" + Thread.currentThread().getId() + " startId:" + startId);
        return Service.START_NOT_STICKY;
    }

    @Override
    public IBinder onBind(Intent intent) {
        throw null;
    }
}

在前臺activity中執行:

//多次執行startService
for(int i = 1; i <= 10; ++i){
    Intent intent = new Intent(this,LGImgCompressorService.class);
    startService(intent);
}

測試時主要觀察打印日志,并查看線程ID,可以得出結論:
onCreate,onDestroy,onStartCommand都是在ui線程(主線程)中執行,onStartCommand執行了10遍,但onCreate和onDestroy只執行了一遍
其中onStartCommand方法最為復雜,Intent intent, int flags, int startId三個參數分別表示的含義大致如下:

  1. intent
    接收前臺啟動sercie時傳入的intent,主要作用于前臺需要給Service傳入相關參數
  2. flags
    標志位,標示本次啟動請求,可能的值有0,START_FLAG_REDELIVERY, START_FLAG_RETRY
  3. startId
    如果多次調用了onStartCommand,如果需要安全的停止Service,這個參數將會很有用
由于Service可能被意外(內存不足)終止,那么系統該如何來處理這個Service呢?這時onStartCommand的返回值就起到作用了:

START_NOT_STICKY:Service被終止后不需要重新啟動,這對執行一次性的后臺操作來說再合適不過了
START_STICKY:Service被終止后需要重新啟動,但是傳給onStartCommand的intent將為null
START_REDELIVER_INTENT:Service被終止后需要重新啟動,這時onStartCommand的intent將為Service銷毀之前最后一個intent

  1. Context.bindService()
    通過bindService來啟動的Service會一直運行,直到所有綁定的客戶端都斷開(unbindService)才會停止。注意這里指的客戶端是指執行綁定Service操作所在的類實例(比如前面的activity,因為activity中執行了startService操作,這時我們稱這個activity為客戶端),本文章主要使用了第一種啟動方式,通過bindService啟動方式將放到后續文章重點討論,還望大家繼續關注。
  • 銷毀Service
    通過startService啟動的服務只能通過Service.stopSelf()或者Context.stopService來停止Service。

  • 和其它組件(比如Activity)的交互
    分為兩種情況:

    1. 如果在前臺能持有Service對象,則可以通過BroadCast(廣播)以及callback回調的方式進行交互
    2. 如果在前臺不能持有Service對象,則只能通過BraodCast或者AIDL的方式來進行交互
      如果是在同一進程中也可以考慮使用EventBus。
      通過廣播的方式非常簡單,只需要在適當的位置調用sendBroadCast()。比如:
public void uploadPicture(Bitmap bitmap){
    ...上傳
    sendBroadCast(new Intent(COMPLETE));
}

不管通過哪種方式,需要注意的是廣播的方式不適合Service和其它組件之間進行大規模的更新操作,比如更新進度條,如果有這方面的需求還是需要通過bindService的方式來綁定服務,因為這樣可以持有Service對象,然后可以通過callback的方式進行回調操作。演示代碼如下:
ServiceTest.java

public class LocalService extends Service{
    private CallBack callback;
    private LocalBinder localBinder = new LocalBinder();
    public IBinder onBind(Intent intent){
        return localBinder;
    }
    public void doTask(){
        new MyTask().execute();
    }
    public void setCallback(CallBack callback){
        this.callback = callback;
    }
    public class LocalBinder extends Binder(){
        public LocalService getService(){
            return LocalService.this;
        }
    }
    private final class MyTask extends AsyncTask<>{
        @override
        protected void onPreExecute(){
            ...
        }
        @override
        protected void onProgressUpdate(){
            ...
            callback.onProgressing();
        }
        @override
        protected void onPostExecute(){
            ...
            callback.onCompleted();
        }
    }
}

MyActivity.java

public class MyActivity extends Activity implements CallBack{
    ...
    LocalService service;
    @override
    protected void onResume(){
        ...
        Intent intent = new Intent(this,LocalService.class);
        bindService(intent,this,BIND_AUTO_CREATE);
    }
    @override
    protected void onPause(){
        ...
        if(service != null){
            service.setCallBack(this);
            unbindService(this)
        }
    }
    //執行后臺任務
    public void onClick(View view){
        if(service != null){
            service.doTask();
        }
    }
    //更新進度ui
    @override
    public void onProgressing(){
        ...
    }
    //綁定成功回調此方法,初始化service成員(調用getService實際就是返回了LocalService實例)
    @override
    public void onServiceConnected(ComponentName name,IBinder iBinder){
        service = ((LocalService.LocalBinder) iBinder).getService();
        service.setCallBack(this);
    }
    //當Service斷開后回調
    @override
    public void onServiceDisconnected(ComponentName name){
        service = null;
    }
}

至于AIDL跨進程交互不在此討論了,這完全可以單獨用個專題來討論的。

最后回到文章主題,現在需要將壓縮任務放到Service中處理,應該考慮的問題是:

  1. 用單線程多任務的方式處理,解決方案如下:
    把所有需要壓縮的任務放到一個任務隊列中,開啟后臺線程挨個處理隊列中的任務,處理完一個移除一個。其實還是很簡單的,那么需要我們自己來維護這個線程和任務隊列嗎?其實android給我們提供了IntentService來專門處理這種情況,其核心思想是在后臺線程生成一個Looper,在Looper中dispatchMessage獲取消息隊列中的消息,在IntentService中創建Handler來發送和處理消息。使用IntentService還有個好處就是不需要我們在手動結束Service。至于IntentService的內部原理,大家可以參考我的文章從源碼分析IntentService,強烈建議閱讀一下
  2. 用多線程多任務的方式處理,解決方案如下:
    這種方式就是啟動多個線程并記錄本次任務總數量,每個線程單獨執行一個壓縮任務,執行完一個任務數量減1,如果最后任務數為0,則停止Service并執行清理操作。由于涉及在一個Service中啟動多個線程,所以必然需要處理所謂的“共享資源的問題”

最后使用代碼演示以上兩種方案的處理:

  • 用單線程多任務的方式處理,由于只是單線程所以不需要考慮“共享資源的問題”,代碼相對簡單清晰
    LGImgCompressorIntentService.java
public class LGImgCompressorIntentService extends IntentService {
    private final String TAG = LGImgCompressorIntentService.class.getSimpleName();

    private static final String ACTION_COMPRESS = "gui.com.lgimagecompressor.action.COMPRESS";

    private ArrayList<LGImgCompressor.CompressResult> compressResults = new ArrayList<>();//存儲壓縮任務的返回結果

    public LGImgCompressorIntentService() {
        super("LGImgCompressorIntentService");
        setIntentRedelivery(false);//避免出異常后service重新啟動
    }

    @Override
    public void onCreate() {
        super.onCreate();
        Intent intent = new Intent(Constanse.ACTION_COMPRESS_BROADCAST);
        intent.putExtra(Constanse.KEY_COMPRESS_FLAG,Constanse.FLAG_BEGAIIN);
        sendBroadcast(intent);
        Log.d(TAG,"onCreate...");
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        Intent intent = new Intent(Constanse.ACTION_COMPRESS_BROADCAST);
        intent.putExtra(Constanse.KEY_COMPRESS_FLAG,Constanse.FLAG_END);
        intent.putParcelableArrayListExtra(Constanse.KEY_COMPRESS_RESULT,compressResults);
        sendBroadcast(intent);//發送壓縮結束廣播
        compressResults.clear();
        Log.d(TAG,"onDestroy...");
    }

    public static void startActionCompress(Context context, CompressServiceParam param) {
        Intent intent = new Intent(context, LGImgCompressorIntentService.class);
        intent.setAction(ACTION_COMPRESS);
        intent.putExtra(Constanse.COMPRESS_PARAM, param);
        context.startService(intent);
    }

    @Override
    protected void onHandleIntent(Intent intent) {
        if (intent != null) {
            final String action = intent.getAction();
            if (ACTION_COMPRESS.equals(action)) {
                //取出從前臺通過intent傳入的壓縮參數
                final CompressServiceParam param1 = intent.getParcelableExtra(Constanse.COMPRESS_PARAM);
                handleActionCompress(param1);
            }
        }
    }
    //執行壓縮操作
    private void handleActionCompress(CompressServiceParam param) {
        int outwidth = param.getOutWidth();
        int outHieight = param.getOutHeight();
        int maxFileSize = param.getMaxFileSize();
        String srcImageUri = param.getSrcImageUri();
        LGImgCompressor.CompressResult compressResult = new LGImgCompressor.CompressResult();
        String outPutPath = null;
        try {
            outPutPath = LGImgCompressor.getInstance(this).compressImage(srcImageUri, outwidth, outHieight, maxFileSize);
        } catch (Exception e) {
        }
        compressResult.setSrcPath(srcImageUri);
        compressResult.setOutPath(outPutPath);
        if (outPutPath == null) {
            compressResult.setStatus(LGImgCompressor.CompressResult.RESULT_ERROR);
        }
        compressResults.add(compressResult);
    }
}

相比上一篇文章的版本,此次新增了CompressResult和CompressServiceParam兩個類,分別用于處理壓縮的返回結果和傳給Service用的壓縮參數
代碼如下(由于篇幅問題省咧了很多代碼,如果需要請轉到我的github地址):

public class CompressServiceParam implements Parcelable {

    private int outWidth;
    private int outHeight;
    private int maxFileSize;
    private String srcImageUri;
    public CompressServiceParam() {
    }
    protected CompressServiceParam(Parcel in) {
        outWidth = in.readInt();
        outHeight = in.readInt();
        maxFileSize = in.readInt();
        srcImageUri = in.readString();
    }
    ...
}

由于通過intent.putXXX()方法要將CompressServiceParam實例put到Intent那么CompressServiceParam必須實現Parcelable接口
CompressResult.java

public static class CompressResult implements Parcelable{
        public static final int RESULT_OK = 0;//成功
        public static final int RESULT_ERROR = 1;//失敗
        private int status = RESULT_OK;//
        private String srcPath;//原圖目錄
        private String outPath;//輸出圖的目錄
        public CompressResult(){
        }
        protected CompressResult(Parcel in) {
            status = in.readInt();
            srcPath = in.readString();
            outPath = in.readString();
        }
        ...
}

最后在ServiceCompressActivity.java中啟動服務,核心代碼如下

ArrayList<Uri> compressFiles = getImagesPathFormAlbum();//獲取所有圖片的uri地址
Log.d(TAG, compressFiles.size() + "compresse begain");
int size = compressFiles.size() > 10 ? 10:compressFiles.size();
for (int i = 0; i < compressFiles.size(); ++i) {
    Uri uri = compressFiles.get(i);
    CompressServiceParam param = new CompressServiceParam();
    param.setOutHeight(800);
    param.setOutWidth(600);
    param.setMaxFileSize(400);
    param.setSrcImageUri(uri.toString());
    LGImgCompressorIntentService.startActionCompress(ServiceCompressActivity.this, param);
}
//廣播接收類
private class CompressingReciver extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
        Log.d(TAG, "onReceive:" + Thread.currentThread().getId());
        int flag = intent.getIntExtra(Constanse.KEY_COMPRESS_FLAG,-1);
        Log.d(TAG," flag:" + flag);
        if(flag == Constanse.FLAG_BEGAIIN){
            return;
        }

        if(flag == Constanse.FLAG_END){
            ArrayList<LGImgCompressor.CompressResult> compressResults =
                    (ArrayList<LGImgCompressor.CompressResult>)intent.getSerializableExtra(Constanse.KEY_COMPRESS_RESULT);
        }
    }
}
@Override
protected void onCreate(Bundle savedInstanceState) {
    //注冊廣播
    reciver = new CompressingReciver();
    IntentFilter intentFilter = new IntentFilter(Constanse.ACTION_COMPRESS_BROADCAST);
    registerReceiver(reciver, intentFilter);
}
@Override
protected void onDestroy() {
    super.onDestroy();
    if(reciver != null){
        unregisterReceiver(reciver);//取消注冊
    }
}
  • 用多線程多任務的方式處理,大部分代碼和單線程類似,只是需要將任務放到線程池中處理并處理好數據安全問題。核心代碼如下:
    LGImgCompressorService.java
public class LGImgCompressorService extends Service {
    public LGImgCompressorService() {
    }

    @Override
    public void onCreate() {
        super.onCreate();
        Log.d(TAG,"onCreate...");
        executorService = Executors.newCachedThreadPool();
//        executorService = Executors.newFixedThreadPool(10);
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        ...
        sendBroadcast(intent);
        compressResults.clear();
        executorService.shutdownNow();
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        doCompressImages(intent,startId);
        return Service.START_NOT_STICKY;
    }
    private int taskNumber;//記錄任務數量
    private ExecutorService executorService;
    private final Object lock = new Object();//對象鎖
    private void doCompressImages(final Intent intent,final int taskId){
        final ArrayList<CompressServiceParam> paramArrayList = intent.getParcelableArrayListExtra(Constanse.COMPRESS_PARAM);
        synchronized (lock){
            taskNumber += paramArrayList.size();
        }
        //如果paramArrayList過大,為了避免"The application may be doing too much work on its main thread"的問題,將任務的創建和執行統一放在后臺線程中執行
        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < paramArrayList.size(); ++i){
                    executorService.execute(new CompressTask(paramArrayList.get(i),taskId));//將任務放入線程池中執行
                }
            }
        }).start();
    }

    private class CompressTask implements Runnable{
        private CompressServiceParam param;
        private int taskId ;

        private CompressTask(CompressServiceParam compressServiceParam,int taskId){
            this.param = compressServiceParam;
            this.taskId = taskId;
        }
        @Override
        public void run() {
            ...
            //加鎖,避免并發修改數據導致臟數據的情況
            synchronized (lock){
                compressResults.add(compressResult);
                taskNumber--;
                if(taskNumber <= 0){
                    stopSelf(taskId);//通過onStartCommand中的startId來正確的關閉Serivce
                }
            }
        }
    }
    @Override
    public IBinder onBind(Intent intent) {
        throw null;
    }
}

兩個方案的對比

我們主要通過內存的使用量和壓縮所耗費的時間來對比下以上兩種方案,我的測試用手機對91個圖片進行壓縮處理后的結果:
方案1:
耗時8211ms
內存圖:

Paste_Image.png

方案2:
耗時1872ms
Paste_Image.png

可以看出方案1的內存消耗比較平穩但是耗時大,而方案2的內存消耗大,內存峰值接近100M。其實發生對于這種情況也是可以理解的,方案1是單線程的一次只處理一個壓縮任務,而方案2是多線程并發的,假設瞬間并發處理90個任務每個任務消耗1M內存,那么在這瞬間將消耗90M內存,再加上線程的創建和消耗所消耗的內存肯定就在90M以上了。
至于哪種方案更好,這需要看實際業務了,這是典型的“用時間換空間”還是用“空間換時間”的問題了。
對于方案2還是可以進行一定的優化的,在Service的onCreate中,我們用了executorService = Executors.newCachedThreadPool();來生成線程池,其底層代碼為:
Executors.java

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                              60L, TimeUnit.SECONDS,
                              new SynchronousQueue<Runnable>());
}

ThreadPoolExecutor前三個參數分別表示:
corePoolSize(核心池大小)
maximumPoolSize(最大線程數量)
keepAliveTime(當池中線程數量大于corePoolSize時,線程等待新任務的的最大時間。比如現在線程池有兩個A,B線程,A線程執行完了任務處于等待新任務的狀態,如果新任務在keepAliveTime時間內還沒有加入進來,那么A線程將被銷毀)。
newCachedThreadPool的默認實現是:核心池大小為0,最大線程數量為MAX_VALUE,保持時間為60秒,也就是說如果方案2中有91個并行處理的任務,那么將生成91個線程,這個數量還是非常大的。
換種方式考慮問題,能不能要線程池只保留有限的線程數,如果任務數超出了線程數則加入等待隊列中,等有空閑的線程時再用這個空閑的線程處理任務?這樣我們即保證了一定的并發數提高了處理速度,同時不會瞬間占用過多的內存開銷。可以通過Executors.newFixedThreadPool(size)來達到上面的目的,將方案2中onCreate,創建線程池的代碼改為:
Executors.newFixedThreadPool(10)得到的測試結果如下:
耗時1712ms

Paste_Image.png

寫在最后

以上方案并不存在絕對的哪個好,哪個壞之分。如果處理的任務數量不多比如40個以下,建議大家使用方案1,具體的數量還需要多測試找到合適點。
如果確實有大量的任務需要處理則采用方案2,但是創建線程池用newFixedThreadPool方式來創建,另外可以考慮將Service以remote方式在另外的進程中執行,這樣其占用的內存將不會占用本app的內存,以remote方式運行只需在配置service的AndroidManifest.xml中以如下方式配置即可:
<service android:name=".LGImgCompressorService" android:process=":lg_remote"/>其中process的:表示其運行在獨立進程中。
最后我們也可以綜合采用方案1和方案2來處理,比如在啟動service之前先判斷當前任務的數量,如果小于一定的值則采用方案1,否則采用方案2這樣動態的采取不同的策略

本篇文章字數較多,感謝大家非常耐心的讀完~~希望本篇文章對大家有所幫助

demo開源github地址如下:
LGImageCompressor
歡迎大家訪問并star,如果有任何問題可以在評論中加以提問,謝謝~~

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,546評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,570評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,505評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,017評論 1 313
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,786評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,219評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,287評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,438評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,971評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,796評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,995評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,540評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,230評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,662評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,918評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,697評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,991評論 2 374

推薦閱讀更多精彩內容

  • 參考: 服務|Android Developers 一. 什么是服務 服務是一個可以在后臺執行長時間運行操作而不提...
    NickelFox閱讀 548評論 0 3
  • 上篇我們講解了Android中的5中等級的進程,分別是:前臺進程、可見進程、服務進程、后臺進程、空進程。系統會按照...
    徐愛卿閱讀 3,873評論 6 33
  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,785評論 18 139
  • 前言:本文所寫的是博主的個人見解,如有錯誤或者不恰當之處,歡迎私信博主,加以改正!原文鏈接,demo鏈接 Serv...
    PassersHowe閱讀 1,436評論 0 5
  • 溪水不因山川的阻擋而止步不前,而是適應地勢終究涌入大海。 向日葵不因太陽的東升西落而暗自哭泣,而是跟著太陽轉終究結...
    獨釣寒江雪iris閱讀 369評論 0 1