introduce

The framework is a generic network layer that can be invoked by the business layer of different apps. The framework encapsulates AFNetworking and in some ways takes a page from the design of YTKNetwork: encapsulate and manage requests as objects.

Functionally, it supports:

  • Send request methods for GET, POST, PUT, DELETE ordinary network request function
  • Image upload function (single or multiple images upload, specify the image compression ratio before uploading)
  • Download function (support resumable and background download)
  • Cache management (write, read, clear cache, calculate size) function
  • Request management (view the status of ongoing requests, single requests and batch cancellations) functionality
  • Set the default key pair in the request body (eg. Need to add version number for versioning)
  • Add request headers (eg. For services that require a token)
  • Setting the Server ADDRESS
  • Set debug mode (print logs that are convenient for debugging, such as reading the specific cause of cache failure)

GitHub: SJNetwork

There is demo in the project: SJNetworkDemo project folder

architecture

Before looking at the architecture diagram, a brief introduction to the responsibilities of each class in the framework:

Responsibility division

The name of the class Duties and responsibilities
SJNetwork Header file, you just need to introduce this file to use all the functionality of the framework
SJNetworkProtocol The processing method after the request ends is customized and may be extended in the future
SJNetworkHeader Defines callback blocks and enumeration types
SJNetworkManager A class that is directly connected to the business layer and contains all interfaces for network request functions except the configuration interface
SJNetworkBaseEngine All the base classes responsible for sending the request class
SJNetworkRequestEngine Classes that send (GET,POST,PUT,DELETE) requests: supports setting cache expiration, reading, writing, and clearing the cache
SJNetworkUploadEngine Send upload request class: support to set the image type and compression upload, batch upload
SJNetworkDownloadEngine Classes that send download requests: support breakpoint continuations and background downloads
SJNetworkRequestModel Request object class: holds some data for a network request; Such as request URL, request body, etc.)
SJNetworkCacheManager Cache handling class: cache write, read, delete
SJNetworkConfig Configuration class: configure the server address, debug mode, etc
SJNetworkUtils Utility class: can be used to generate cache path, app version number, etc
SJNetworkRequestPool Request object pool: Used to hold in-progress request objects
SJNetworkCacheInfo Cache metadata: Records information about the corresponding cache data (version number, cache expiration time)
SJNetworkDownloadResumeDataInfo Metadata for undownloaded data: Records information about undownloaded data (proportion of downloaded data, total length of downloaded data, length of downloaded data)

Architecture diagram

As can be seen from the architecture diagram:

  • Business side callSJNetworkManagerTo send requests (or perform operations such as operation requests), while the class that actually does the work isSJNetworkRequestEngine.SJNetworkUploadEngine.SJNetworkDownloadEngine.SJNetworkCacheManagerThese classes.
  • All requests are encapsulated into oneSJNetworkRequestModelInstance to be managed bySJNetworkRequestPool.
  • Within that framework,SJNetworkConfigandSJNetworkUtilsCan be called anywhere, because it is often necessary to get some configuration done by the user and to call some common utility class methods.

Method of use

Step1: download and import the framework

Through the Cocoa pods:

pod 'SJNetwork'

The latest version is 1.2.0

Or manually drag the SJNetwork folder into the project.

Step2: Import header file:

If you are using Cocoapods:

import <SJNetwork/SJNetwork.h>
Copy the code

If it is manually dragged:

#import "SJNetwork.h"
Copy the code

Function is introduced

Basic configuration

Because the configuration object is a singleton (SJNetworkConfig), it can be used anywhere in the project. Take a look at what configuration items the framework supports:

Server Address:

[SJNetworkConfig sharedConfig].baseUrl = @"http://v.juhe.cn";
Copy the code

Default parameters:

[SJNetworkConfig sharedConfig].defailtParameters = @{@"app_version":[SJNetworkUtils appVersionStr],
                                                        @"platform":@"iOS"};
Copy the code

Default parameters are concatenated in the request body of all requests;

If it is a GET request, the concatenation is in the URL.

Timeout period:

[SJNetworkConfig sharedConfig].timeoutSeconds = 30;
Copy the code

The default timeout period is 20s.

The Debug mode:

[SJNetworkConfig sharedConfig].debugMode = YES; // The default is NOCopy the code

If the debug mode is set to YES, many detailed logs are printed for debugging.

If set to NO, there is NO log.

Add a request header key-value pair:

[[SJNetworkConfig sharedConfig] addCustomHeader:@{@"token":@"2j4jd9s74bfm9sn3"}];
Copy the code

or

[[SJNetworkManager sharedManager] addCustomHeader:@{@"token":@"2j4jd9s74bfm9sn3"}]; // The SJNetworkConfig addCustomHeader method is actually calledCopy the code

The added header key-value pairs are automatically added to all request headers.

Add a key-value pair if it does not already exist; If it exists, replace it.

Common Network request

Here we define GET, POST, PUT, DELETE requests as ordinary network requests, which are implemented by SJNetworkRequestManager. All of these common network requests support either write or read caching, but are not supported by default, leaving it up to the user to decide whether to write or read the cache.

Send a POST request that does not support either write or read caching:

[[SJNetworkManager sharedManager] sendPostRequest:@"toutiao/index"
                                       parameters:@{@"type":@"top",
                                                    @"key" :@"0c60"}
                                          success:^(id responseObject) {

      NSLog(@"request succeed:%@",responseObject);

  } failure:^(NSURLSessionTask *task, NSError *error, NSInteger statusCode) {

      NSLog(@"request failed:%@",error);
  }];
Copy the code

Send a POST request that supports a write duration of 180 seconds and reads the cache while it is valid:

[[SJNetworkManager sharedManager] sendPostRequest:@"toutiao/index"
                                       parameters:@{@"type":@"top",
                                                    @"key" :@"0c60"}
                                        loadCache:YES
                                    cacheDuration:180
                                          success:^(id responseObject) {

     NSLog(@"request succeed:%@",responseObject);

 } failure:^(NSURLSessionTask *task, NSError *error, NSInteger statusCode) {

     NSLog(@"request failed:%@",error);
 }];
Copy the code

CacheDuration: cacheDuration, in seconds.

  • If it is greater than 0, cache it.
  • If the value is less than or equal to 0, no caching is performed.

LoadCache: If set to YES, check whether the cache is valid before sending the request (if set to NO, the network request will be made with or without the cache) :

  • If the cache exists and is valid, the cache is returned and no network request is made.
  • If the cache does not exist, or if it exists but is invalid (time expired), the cache is removed (if it exists) and the network request is made.

A complete flow chart of a normal network request with caching judgment:

Cache management

Cache management is implemented by SJNetworkCacheManager singleton, functions are divided into cache read, delete and calculation. Let’s take a look at cache reads:

Cache read

The framework supports reads from a single cache and reads from multiple caches:

  • A single cache read returns either a cache object (dictionary, or array) or nil.
  • Multiple cache reads return an array or nil.

A single cache read:

If we know the url, method, or body of this cache, we can try to get the cache object that corresponds to it:

For example, if you want to cache a network request that has been written to the cache above, you can use the following API:

[[SJNetworkManager sharedManager] loadCacheWithUrl:@"toutiao/index"
                                            method:@"POST"
                                        parameters:@{@"type":@"top",
                                                   @"key" :@"0c60"}
                                   completionBlock:^(id  _Nullable cacheObject) {
                               
    NSLog(@"%@",cacheObject);
                               
}];
Copy the code

Note that the following situations occur during cache reads:

  • If the cache for this request does not exist, then nil will be passed from the block.

  • If the cache for the request exists but expires, the cache will be cleared and the request will be passed nil in the block.

  • If the cache corresponding to the request exists and is valid, the cache object will be passed from the block.

Multiple cache reads:

If some requests are cached using the same URL (but with different request methods or parameters), they can be cached using the following methods:

[[SJNetworkManager sharedManager] loadCacheWithUrl:@"toutiao/index"
                                   completionBlock:^(NSArray * _Nullable cacheArr) {
    NSLog(@"%@",cacheArr);
}];
Copy the code

If some requests use the same URL and request method, but the request parameters are different, they can be cached (in an array) by:

[[SJNetworkManager sharedManager] loadCacheWithUrl:@"toutiao/index" method:@"POST" completionBlock:^(NSArray * _Nullable  cacheArr) { NSLog(@"%@",cacheArr); }];Copy the code

Now that we know that the framework supports single and batch cache reads, let’s look at cache removal:

Cache deletion

Similarly, the framework supports single and batch deletes of caches.

If you want to remove requests from the cache that belong to a particular URL, method, or request parameter, you can use the following API:

[[SJNetworkManager sharedManager] clearCacheWithUrl:@"toutiao/index"
                                             method:@"POST"
                                         parameters:@{@"type":@"top",
                                                      @"key" :@"0c60"}
                                    completionBlock:^(BOOL isSuccess) {

     if (isSuccess) {
       NSLog(@"Clearing cache successfully!");
     }
}];
Copy the code

If you want to remove cached requests that use the same URL (but different request methods or parameters), use the following API:

[[SJNetworkManager sharedManager] clearCacheWithUrl:@"toutiao/index"
                                    completionBlock:^(BOOL isSuccess) {

     if (isSuccess) {
       NSLog(@"Clearing cache successfully!");
     }
}];
Copy the code

If you want to remove cached requests that use the same URL and method, but different request parameters, use the following API:

[[SJNetworkManager sharedManager] clearCacheWithUrl:@"toutiao/index"
                                             method:@"POST"
                                withCompletionBlock:^(BOOL isSuccess) {
     if (isSuccess) {
        NSLog(@"Clearing cache successfully!");
     }
  }];
Copy the code

Having looked at cache reads and deletes, let’s look at the cache calculation:

Cache calculation

The cache calculation provides only one interface, and the block callback will return a number of files, the size of all caches, and a string of KB or MB:

[[SJNetworkManager sharedManager] calculateCacheSizeWithCompletionBlock:^(NSUInteger fileCount, NSUInteger totalSize, NSString *totalSizeString) {
        
        NSLog(@"file count :%lu and total size:%lu total size string:%@",(unsigned long)fileCount,(unsigned long)totalSize, totalSizeString);
 }];
Copy the code

FileCount: indicates the number of cached files. The value is an integer

Total size: The unit is byte

TotalSizeString: string with KB and MB conversion: in KB units up to 1024*1024 bytes; Other units are in MB. For example, file count :5 and total size:1298609 Total size string:1.2385 MB

** Note: ** the caches calculated include the caches of all common requests as well as the data that has not been downloaded and needs to be downloaded later.

Upload function

The function of uploading images is implemented by the singleton of SJNetworkUploadManager class: support for uploading single or multiple UIImage objects, can set compression ratio (default is 1 when not set, no compression).

Single picture, original upload

Upload a single UIImage object without compressing the image before uploading:

[[SJNetworkManager sharedManager]  sendUploadImageRequest:@"api"
                                               parameters:nil
                                                    image:image_1
                                                     name:@"color"
                                                 mimeType:@"png"
                                                 progress:^(NSProgress *uploadProgress) 
{

    self.progressView.observedProgress = uploadProgress;

} success:^(id responseObject) {

    NSLog(@"upload succeed");

} failure:^(NSURLSessionTask *task, NSError *error, NSInteger statusCode, NSArray<UIImage *> *uploadFailedImages) {

    NSLog(@"upload failed, failed images:%@",uploadFailedImages);

}];
Copy the code

Multiple images, compressed in half

Upload multiple UIImage objects with a compression ratio of 0.5:

[[SJNetworkManager sharedManager] sendUploadImagesRequest:@"api" parameters:nil images:@[image_1,image_2] CompressRatio :0.5 name:@"images" mimeType:@" JPG "progress:^(NSProgress *uploadProgress) { self.progressView.observedProgress = uploadProgress; } success:^(id responseObject) { NSLog(@"upload succeed"); } failure:^(NSURLSessionTask *task, NSError *error, NSInteger statusCode, NSArray<UIImage *> *uploadFailedImages) { NSLog(@"upload failed, failed images:%@",uploadFailedImages); }];Copy the code

Here, the mimeType can be set to JPG /JPG, PNG /PNG, jpeg/JPEG, as the image type when uploaded to the server. Note that if mimeType is PNG /PNG, the compression ratio is invalid and will be uploaded at the original size.

Ignore the set BaseUrl

Considering that the server on which the image is uploaded may be different from the server on which the normal request is made, a special parameter is added: ignoreBaseUrl. If the Boolean value is set to YES, the baseUrl set in the SJNetworkConfig singleton is ignored and the user needs to write the full request URL as the first parameter of the request:

[[SJNetworkManager sharedManager] sendUploadImagesRequest:@"http://uploads.im/api" ignoreBaseUrl:YES parameters:nil Images :@[image_1,image_2] compressRatio:0.5 name:@"images" mimeType:@" JPG "progress:^(NSProgress *uploadProgress) { self.progressView.observedProgress = uploadProgress; } success:^(id responseObject) { NSLog(@"upload succeed"); } failure:^(NSURLSessionTask *task, NSError *error, NSInteger statusCode, NSArray<UIImage *> *uploadFailedImages) { NSLog(@"upload failed, failed images:%@",uploadFailedImages); }];Copy the code

Another option is to force a change to the baseUrl of the SJNetworkConfig singleton:

[SJNetworkConfig sharedConfig].baseUrl = @"http://uploads.im"; [[SJNetworkManager sharedManager] sendUploadImagesRequest:@"api" ignoreBaseUrl:NO parameters:nil Images :@[image_3,image_4] compressRatio: @"color" mimeType:@" PNG "progress:^(NSProgress *uploadProgress) { self.progressView.observedProgress = uploadProgress; } success:^(id responseObject) { NSLog(@"upload succeed"); } failure:^(NSURLSessionTask *task, NSError *error, NSInteger statusCode, NSArray<UIImage *> *uploadFailedImages) { NSLog(@"upload failed, failed images:%@",uploadFailedImages); }];Copy the code

It doesn’t look very elegant, but it’s doable: just change the baseUrl back again before requesting all the normal network requests.

For the time being, the framework does not support multiple baseUrl functions, but this will be added if further research is done.

Download function

Download function is by SJNetworkDownloadManager singleton to achieve, support breakpoint continuation and background download.

  • If it is set to support background download, the generated task class internally is:NSURLSessionDownloadTask. After the phone exits the foreground and enters the background, it can still be downloaded.
  • If the background download is not supported, then the generated task class is:NSURLSessionDataTask. The download cannot continue after the phone exits the foreground, but through the frame internalAutomatically restore the download mechanismAfter returning to the foreground, the download will continue. And it combinesNSOutputStreamExample, the downloaded data is written in the sandbox bit by bit, reducing the pressure on memory, which is to support large file downloads.
  • If resumable data is supported, the data that has not been downloaded will be saved after the download fails due to network disconnection or cancellation. After the download task is started later, the download continues.
  • If breakpoint continuation is not supported, the downloaded data will not be retained. You can only start from scratch after you start the download later.

In summary, there are four situations:

Support breakpoint continuation Breakpoint continued is not supported
Support background download
Background download is not supported

The default configuration is: support breakpoint resume, not support background download (because unless it is a special app such as music video, background download operation may be rejected by Apple).

Download the interface

Default download function (resumable download is supported, background download is not supported) :

[[SJNetworkManager sharedManager] sendDownloadRequest:@"wallpaper.jpg" downloadFilePath:_imageFileLocalPath progress:^(NSInteger receivedSize, NSInteger expectedSize, CGFloat progress) { self.progressView.progress = progress; } success:^(id responseObject) { NSLog(@"Download succeed!");  } failure:^(NSURLSessionTask *task, NSError *error, NSString *resumableDataPath) { NSLog(@"Download failed!"); }];Copy the code

If resumableDataPath is supported, the path of the undownloaded data will be passed in the callback that fails to return: resumableDataPath.

Breakpoint continuation is not supported, and background download is not supported:

[[SJNetworkManager sharedManager] sendDownloadRequest:@"half-eatch.jpg" downloadFilePath:_imageFileLocalPath resumable:NO backgroundSupport:NO progress:^(NSInteger receivedSize, NSInteger expectedSize, CGFloat progress) { self.progressView.progress = progress; } success:^(id responseObject) { NSLog(@"Download succeed!");  } failure:^(NSURLSessionTask *task, NSError *error, NSString *resumableDataPath) { NSLog(@"Download failed!"); }];Copy the code

Support breakpoint continuation, support background download:

[[SJNetworkManager sharedManager] sendDownloadRequest:@"universe.jpg" downloadFilePath:_imageFileLocalPath resumable:YES  backgroundSupport:YES progress:^(NSInteger receivedSize, NSInteger expectedSize, CGFloat progress) { self.progressView.progress = progress; } success:^(id responseObject) { NSLog(@"Download succeed!");  } failure:^(NSURLSessionTask *task, NSError *error, NSString *resumableDataPath) { NSLog(@"Download failed!"); }];Copy the code

Resumable transfer is not supported, but background download is supported:

[[SJNetworkManager sharedManager] sendDownloadRequest:@"iceberg.jpg" downloadFilePath:_imageFileLocalPath resumable:NO backgroundSupport:YES progress:^(NSInteger receivedSize, NSInteger expectedSize, CGFloat progress) { self.progressView.progress = progress; } success:^(id responseObject) { NSLog(@"Download succeed!");  } failure:^(NSURLSessionTask *task, NSError *error, NSString *resumableDataPath) { NSLog(@"Download failed!"); }];Copy the code

As with upload, the download interface also supports ignoring baseUrl:

[[SJNetworkManager sharedManager] sendDownloadRequest:@"http://oih3a9o4n.bkt.clouddn.com/wallpaper.jpg" ignoreBaseUrl:YES downloadFilePath:_imageFileLocalPath progress:^(NSInteger receivedSize, NSInteger expectedSize, CGFloat progress) { self.progressView.progress = progress; } success:^(id responseObject) { NSLog(@"Download succeed!");  } failure:^(NSURLSessionTask *task, NSError *error, NSString *resumableDataPath) { NSLog(@"Download failed!"); }];Copy the code

Suspend, resume and cancel downloads

All download requests support pause, resume, and cancel operations. And these operations are supported individually and in batches:

Suspension of downloads

Pause individual download requests:

[[SJNetworkManager sharedManager] suspendDownloadRequest:@"universe.jpg"];
Copy the code

Pause multiple download requests:

[[SJNetworkManager sharedManager] suspendDownloadRequests:@[@"universe.jpg",@"wallpaper.jpg"]];
Copy the code

Suspend all download requests:

[[SJNetworkManager sharedManager] suspendAllDownloadRequests];
Copy the code

Download recovery

Resume individual suspended download requests:

[[SJNetworkManager sharedManager] resumeDownloadReqeust:@"universe.jpg"];
Copy the code

Resume multiple suspended download requests:

[[SJNetworkManager sharedManager] resumeDownloadReqeusts:@[@"universe.jpg",@"wallpaper.jpg"]];
Copy the code

Resume all pending download requests:

[[SJNetworkManager sharedManager] resumeAllDownloadRequests];
Copy the code

Cancellation of download

Cancel a separate download request:

[[SJNetworkManager sharedManager] cancelDownloadRequest:@"universe.jpg"];
Copy the code

Cancel multiple download requests:

[[SJNetworkManager sharedManager] cancelDownloadRequests:@[@"universe.jpg",@"wallpaper.jpg"]];
Copy the code

Cancel all download requests:

[[SJNetworkManager sharedManager] cancelAllDownloadRequests];
Copy the code

Management of requests

In this framework, both normal download requests, upload requests, and download requests save the parameters passed in by the user in an instance of the special request object SJNetworkRequestModel before sending the request. These instances are managed by singletons of the SJNetworkRequestPool class:

  • Before the request begins, the request instance is managed in one of the dictionaries.
  • When the request is complete, the corresponding request instance is removed.

In addition to adding and removing request objects, SJNetworkRequestPool manages requests by:

  • A query of the status of an ongoing request
  • Cancellation of a request in progress.

A query of the status of the request

Are there still ongoing requests:

BOOL remaining =  [[SJNetworkManager sharedManager] remainingCurrentRequests];
if (remaining) {
    NSLog(@"There is remaining request");
}
Copy the code

Number of requests in progress:

NSUInteger count = [[SJNetworkManager sharedManager] currentRequestCount];
if (count > 0) {
    NSLog(@"There is %lu requests",(unsigned long)count);
}
Copy the code

Print all request objects in progress:

[[SJNetworkManager sharedManager] logAllCurrentRequests];
Copy the code

Cancellation of request

Cancellations of requests are also divided into individual and batch cancellations:

Cancel a request:

[[SJNetworkManager sharedManager] cancelCurrentRequestWithUrl:@"toutiao/index"
                                                           method:@"POST"
                                                       parameters:@{@"type":@"top",
                                                                    @"key" :@"0c60"}];
Copy the code

Cancel requests for the same URL:

[[SJNetworkManager sharedManager] cancelCurrentRequestWithUrl:@"toutiao/index"];
Copy the code

Cancel multiple requests at the specified URL:

[[SJNetworkManager sharedManager] cancelDownloadRequests:@[@"toutiao/index",@"weixin/query"]];
Copy the code

Cancel all ongoing requests:

[[SJNetworkManager sharedManager] cancelAllCurrentRequests];
Copy the code

The Log output

If the debug mode is set to YES, many debug logs are printed:

[SJNetworkConfig sharedConfig].debugMode = YES;
Copy the code

Log of the request object

The description method of SJNetworkRequestModel is overwritten, so when the object is printed, ordinary network request, upload request, download request have their own log. Here is a common request log:

{ <SJNetworkRequestModel: 0x6040001fc100> type: ordinary request method: GET url: http://v.juhe.cn/toutiao/index parameters: {" app_version "=" 1.0 "; key = 0c60; platform = iOS; type = top; } loadCache: YES cacheDuration: 5 seconds requestIdentifer:b4b36793efabad54a14389cf09bc8133_a6a72ddee1dd86825cb5707c500784f5_7b65261ff298c6a386c89a632bd17b39_30c9 b994c268547f38a2f9af6f8c171f task: <__NSCFLocalDataTask: 0x7f8e075320a0>{ taskIdentifier: 1 } { completed } }Copy the code

Requested and cached logs

Consider a case where a network request needs to be cached but the cache expires:

=========== Load cache info failed, reason:Cache is expired, begin to clear cache... =========== Load cache failed: Cache info is invalid =========== Faild to load cache, start to sending network request... =========== Start requesting... = = = = = = = = = = = url: http://v.juhe.cn/toutiao/index = = = = = = = = = = = method: GET = = = = = = = = = = = the parameters: {" app_version "=" 1.0 "; key = 0c60; platform = iOS; type = top; } =========== Request succeed! =========== Request url:http://v.juhe.cn/toutiao/index =========== Response object:{ code = 200, msg = "", data = {} } =========== Write cache succeed! =========== cache object: { code = 200, msg = "", data = {} } =========== Cache path: /Users/****/***/***/*******.cacheData =========== Available duration: 180 secondsCopy the code

Thank you

I’ve used a lot of web resources, read a lot of articles, and got a lot of help while debugging this framework, so I have to mention this:

API service

  • Free interface service: aggregate data
  • Upload image interface service: Uploads
  • Download pictures of the chart bed: seven cattle cloud developer platform

Use and reference frames

  • AFNetworking
  • YTKNetwork

Refer to the article

  • IOS development network article file download, large file download, breakpoint download
  • IOS uses NSURLSession for downloads (including background downloads, breakpoint downloads)
  • 2. NSURLSessionDownloadTask file download
  • MCDownloadManager ios file download manager
  • IOS file download, breakpoint download (Network request based on NSURLSession)

The last word

At present, there are many frameworks for AFNetworking, uploading pictures, and downloading, but I always want to write a framework of my own code style, and the network layer is a little challenging for me, so I want to try it.

Excluding the interval, the framework took more than a month to complete, but it took another half a month to write the full English comments, finish the refactoring (modifying the architecture, separating some classes), naming the specification, fixing bugs, optimizing, and so on. Especially because they are not familiar with the download this piece, especially the breakpoint to continue, the background download of these two aspects are not practical experience, in writing the time also spent a lot of time.

I hope you can give me more valuable opinions and suggestions, and I will update if I find any shortcomings


This post has been synchronized to a personal blog: Portal

— — — — — — — — — — — — — — — — — — — — — — — — — — — — on July 17, 2018 update — — — — — — — — — — — — — — — — — — — — — — — — — — — —

Pay attention!!

The author recently opened a personal public account, mainly to share programming, reading notes, thinking articles.

  • Programming articles: a selection of my previous technical articles, as well as subsequent technical articles (mainly original), will gradually move away from iOS content and focus on improving programming capabilities.
  • Reading notes: Share your reading notes on programming, thinking, psychology, and workplace books.
  • Thinking articles: share my thoughts on technology and life.

Because there is a limit to the number of messages published on the official account every day, so far we have not published all the selected articles in the past on the official account, and will be published gradually in the future.

And because of the various limitations of the major blog platform, later will be published on the public account some short and concise, to see the big dry goods articles oh ~

Scan the qr code below and click follow, looking forward to growing together with you ~