ResourceLoaderDelegate實(shí)現(xiàn)AVPlayer緩存邊播邊下視頻播放器

前言

iOS多媒體播放主要有2個(gè)技術(shù)層框架可以實(shí)現(xiàn):

  • AVFoundation庫:OC語言對底層進(jìn)行封裝的高級層接口,其中處理音頻、視頻播放功能的是AVPlayer。優(yōu)點(diǎn):由于AVPlayer已經(jīng)對底層諸如音視頻采集、解編碼等細(xì)節(jié)封裝了,應(yīng)用層不需要關(guān)心這些實(shí)現(xiàn)細(xì)節(jié),所以使用簡單,普通開發(fā)者可以不用知道什么是碼率、采樣率等音視頻專業(yè)知識,即可實(shí)現(xiàn)音視頻播放的功能。缺點(diǎn)就是:由于高度封裝,靈活性較差,例如沒有開放諸如緩存的存取的API,給開發(fā)者控制視頻緩存帶來了難度。

  • AudioToolBox: 采用較底層的C語音實(shí)現(xiàn)的音視頻的采集、I/O處理、解碼、編碼、PCM等處理的API集合。優(yōu)點(diǎn)是:靈活度高,開發(fā)者可以開發(fā)出專業(yè)的音視頻播放軟件,主要是供音視頻專業(yè)技術(shù)開發(fā)者使用。但對于非音視頻專業(yè)的普通iOS開發(fā)者并不友好,對音視頻領(lǐng)域不是很了解的話,有一定的門檻。

作為非音視頻專業(yè)領(lǐng)域,只是個(gè)APP應(yīng)用的iOS開發(fā)者,AudioToolBox是沒有把握的,寫出來也是一堆bug哈哈~
所以主要還是利用AVPlayer實(shí)現(xiàn)播放器,可是如果想播放完一次視頻后,下次可以利用緩存播放,AVPlayer并不提供緩存API,我們沒法知道AVPlayer的緩存在哪里。經(jīng)過研究發(fā)現(xiàn),目前實(shí)現(xiàn)帶緩存功能的AVPlayer播放器主要從2個(gè)方向:

  • 在播放視頻的同時(shí),開啟一個(gè)線程下載該視頻URL。
  • 利用AVAssetResourceLoaderDelegate控制視頻數(shù)據(jù)流的請求。

毫無疑問,第一種方案播放一個(gè)視頻,需要耗費(fèi)用戶2倍的流量;而第二種方案只要一遍的流量,既播放了視頻、又緩存了視頻,所以,我的技術(shù)方案就是采用AVAssetResourceLoaderDelegate實(shí)現(xiàn)。

方案思路

AVAssetResourceLoaderDelegate
首先了解一下AVAssetResourceLoaderDelegate所在的層:

ResouceLoader層次圖

其中核心類:

  • AVAssetResourceLoader:這個(gè)類負(fù)責(zé)多媒體(音視頻)二進(jìn)制數(shù)據(jù)的加載(下載),然后回調(diào)給上層Asset,讓視頻播放。但是這個(gè)類作為AVURLAsset是只讀屬性,但是它允許下面這個(gè)代理去如何加載數(shù)據(jù)資源。

  • AVAssetResourceLoaderDelegate:它是一個(gè)協(xié)議,那么任何實(shí)現(xiàn)了該協(xié)議的對象都可以充當(dāng)AVAssetResourceLoader的代理來指示視頻數(shù)據(jù)的加載,既然數(shù)據(jù)資源可以有開發(fā)人員自行加載然后再回填給播放器,那么緩存就可以有自己控制了,OK,這就是我們這個(gè)方案的思路。

注意:通過測試發(fā)現(xiàn),如果給AVURLAssert設(shè)置成正??梢韵螺d的URL時(shí),AVAssetResourceLoaderDelegate的代理是不觸發(fā)的,很可能的推測就是AVAssetResourceLoader解析資源URL做了判斷(偽碼):

if (URL可以自行解析下載) {
內(nèi)部自己解析...
} else {
由外部AVAssetResourceLoaderDelegate解析
}

所以,我們?yōu)榱俗孉VURLAssert強(qiáng)行走外部代理解析,我們可以故意給AVURLAssert傳一個(gè)不合法的URL(為了讓AVAssetResourceLoader不能正常解析URL),我們可以在正確的URL前面拼接約定好的標(biāo)識,然后在后面我們真正去下載前,再將特定的標(biāo)識去掉即可得到能正常下載的URL了。大意是這樣:


拼接URL示意圖

架構(gòu)設(shè)計(jì)

  • KWResourceLoader:該類負(fù)責(zé)AVAssetResourceLoaderDelegate代理的2個(gè)實(shí)現(xiàn)方法:

resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest

resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest

上面shouldWait代理表示要等待加載的資源,在播放中會觸發(fā)多次,以便于分片加載資源,resourceLoader中5個(gè)我們需要關(guān)心的:

1.request:請求資源的URL

2.contentInfomationRequest:這個(gè)里面包含了該音視頻資源的頭部信息,如視頻的格式、總長度字節(jié)數(shù)、是否支持分片下載等重要信息。這些信息需要我們下載視頻的時(shí)候自行填充這些信息,以便AVPlayer 播放前知道視頻的duration和格式信息,如果我們不填充視頻頭信息,視頻是無法播放的,這點(diǎn)是需要注意的地方。

3.dataRequest:這個(gè)里面含有每次分片加載資源的位置offset和請求的長度length信息,以便于我們下載器分片下載對應(yīng)的data.

4.finishWithLoading/withError: 每次音視頻data片段加載加載完畢后,我們要finishLoading ,目的是通知播放器本次資源加載結(jié)束,那么AVAssetResourceLoaderDelegate就又會觸發(fā)shouldWait方法讓我們繼續(xù)加載后面的data,如此反復(fù),直到資源data全部加載完畢。

5.responseWithData: 在finishLoading之前,我們要將不斷下載得到的data數(shù)據(jù)不斷的塞給resouceLoader,以便播放器在一邊下載數(shù)據(jù)的同時(shí)一邊開始播放。

整體架構(gòu)流程圖如下:

架構(gòu)流程圖

實(shí)現(xiàn)細(xì)節(jié):

KWResouceLoader
  • 給AVURLAsset的URL添加特定頭部,以便resouceLoader不能正常解析,從而觸發(fā)shouldWait。
- (NSURL *)assetURLWithURL:(NSURL *)url {
    if (!url) {
        return nil;
    }
    NSURL *assetURL = [NSURL URLWithString:[kCacheScheme stringByAppendingString:[url absoluteString]]];
    return assetURL;
}

然后把拼接的URL傳給AVURLAsset:

//將URL拼接特定標(biāo)識,目的是讓AVURLAsset不能自行下載,從而觸發(fā)shouldwait
    url = [self.loadManager assetURLWithURL:url];
    self.asset = [AVURLAsset URLAssetWithURL:url options:nil];
    [self.asset.resourceLoader setDelegate:self queue:dispatch_get_main_queue()];

設(shè)置resouceLoader的delegate, 即可觸發(fā)下面代理方法:

#pragma mark - AVAssetResourceLoaderDelegate
//開始等待加載資源
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest NS_AVAILABLE(10_9, 6_0) {
    NSURL *resourceURL = [loadingRequest.request URL];
    [KWLog kwLog:@"開始等待資源:%lld-%ld",loadingRequest.dataRequest.requestedOffset,
     (long)loadingRequest.dataRequest.requestedLength];
    if ([resourceURL.absoluteString hasPrefix:kCacheScheme]) {
        //將該資源請求放入待下載列表里
        [self.loadManager addResourceLoadReqeust:loadingRequest];
        return YES;
    }else {
        return NO;
    }
}


//取消下載觸發(fā)
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest NS_AVAILABLE(10_9, 7_0) {
    [KWLog kwLog:@"取消加載的資源:%lld-%ld",loadingRequest.dataRequest.requestedOffset,
          (long)loadingRequest.dataRequest.requestedLength];
    //取消下載
    [self.loadManager cancelResourceLoadReqeust:loadingRequest];
}

上面第一個(gè)是將要加載加載某個(gè)URL片段,這個(gè)會多次觸發(fā),而且可能一次可能會觸發(fā)多次片段請求,所以我們應(yīng)該用一個(gè)數(shù)組來保存每次的request,最后在全部加載完后移除。

第二個(gè)是觸發(fā)取消下載的委托:通過大量的測試發(fā)現(xiàn),這個(gè)取消觸發(fā)一般有2種情況下回出現(xiàn):

  • 當(dāng)前request片段較長,一般是一個(gè)請求至尾的大片段,而當(dāng)前網(wǎng)絡(luò)加載data資源網(wǎng)速欠佳,resouceLoader會取消這次請求,然后改成多個(gè)小分片請求,以保證播放的流暢性。

  • 用戶進(jìn)行seek操作。當(dāng)用戶拖動進(jìn)度至一個(gè)尚未下載(加載)的進(jìn)度的時(shí)候,為了立即加載新的進(jìn)度的資源,會把之前正在加載的請求取消掉。

當(dāng)觸發(fā)了取消代理時(shí),我們應(yīng)該把正在下載的Task cancel掉,以節(jié)省用戶的流量。當(dāng)然,如果你不取消之前的Task也是可以的,這里我還是遵從Apple的代理,將正在下載的Task取消吧。

KWResouceLoader這個(gè)類不負(fù)責(zé)具體資源的加載、取消邏輯,它委托了KWResouceLoaderManager這個(gè)類負(fù)責(zé):

@protocol KWResourceLoadManagerDelegate <NSObject>

@required
//開始填充頭部信息
- (void)resouceLoadManager:(KWResourceLoadManager *)manager
     fillContentInfomation:(KWHttpInfomation *)infomation
               loadReqeust:(AVAssetResourceLoadingRequest *)request;
//接收數(shù)據(jù)
- (void)resouceLoadManager:(KWResourceLoadManager *)manager
            didReceiveData:(NSData *)data
               loadReqeust:(AVAssetResourceLoadingRequest *)request;
//加載資源結(jié)束
- (void)resouceLoadManager:(KWResourceLoadManager *)manager
      didCompleteWithError:(nullable NSError *)error
               loadReqeust:(AVAssetResourceLoadingRequest *)request;

@optional
//資源加載進(jìn)度
- (void)resouceLoadManager:(KWResourceLoadManager *)manager
      resourceLoadProgress:(float)progress
               loadReqeust:(AVAssetResourceLoadingRequest *)request;

@end

fillContentInfomation: 得到資源的頭信息,為播放做準(zhǔn)備
didReceiveData:得到的data調(diào)用responseWithData塞給播放器播放。
completeWithError: 本次request結(jié)束,觸發(fā)下一輪資源請求。

KWResouceLoaderManager

該類是整個(gè)框架的核心,它維護(hù)了一個(gè)所有要加載資源的隊(duì)列,并實(shí)現(xiàn)了一個(gè)消費(fèi)-生產(chǎn)模式,以保證了加載的順序,以及判斷從本地緩存還是網(wǎng)絡(luò)下載該片段。

消費(fèi)-生產(chǎn)模式
當(dāng)shouldWait觸發(fā)時(shí),說明有新的請求過來了,我們首先將request加到隊(duì)列中,然后判斷當(dāng)前是否繁忙(由于同一時(shí)刻只能有一個(gè)資源請求,所以我們應(yīng)該按順序請求資源):

- (void)addResourceLoadReqeust:(AVAssetResourceLoadingRequest *)request {
    [self.loadRequestArray addObject:request];
    if (self.isRunning) {
        //當(dāng)前有正在加載的資源,新添加進(jìn)來資源,排隊(duì)
        return;
    }
    //空閑狀態(tài),立即開始加載資源
    [self beginLoadResource:request];
}

我用了isRunning標(biāo)識表示當(dāng)前是否有別的資源正在加載,如果有,將返回true,新的資源只能待定,否則即時(shí)“空閑狀態(tài)”,可以立刻加載新的資源。

判斷從緩存還是下載獲取資源
根據(jù)request我們獲取要請求的Range:

NSURL *URL = [self originURL:request.request.URL];
long long offset = request.dataRequest.requestedOffset;
long long length = request.dataRequest.requestedLength;

然后根據(jù)range判斷本地是否有該判斷緩存,如果沒有,則下載:

[KWFileManager readLocalBytesOfURL:URL range:NSMakeRange(offset, length) finish:^(NSData * _Nonnull data, NSError * _Nonnull error) {
        if (data && !error) {
            [KWLog kwLog:@"使用緩存"];
            [self finishLoadRequest:request withLocalCacheData:data];
            //加載下一個(gè)資源
            [self loadNextToLoadedResource:request];
        }else {
            [KWLog kwLog:@"沒有緩存,開始下載資源"];
            NSURLSessionTask *task = [self.downloader downloadURL:URL range:NSMakeRange(offset, length)];
            [self.downloadTasks addObject:task];
        }
    }];

如果本地緩存獲取到data,(緩存存取由KWFileManager實(shí)現(xiàn),后面再具體說)則將data和頭信息回調(diào)給上層,然后繼續(xù)加載下一個(gè)資源;如果沒有緩存,則開啟一個(gè)下載任務(wù)。

downloader是由KWResouceDowloader實(shí)現(xiàn):

下載我主要是采用NSURLSessionTask實(shí)現(xiàn),由于要指定下載片段,所以我們r(jià)equest要設(shè)置HTTPHeaderField字段range:

- (NSURLSessionTask *)downloadURL:(NSURL *)URL range:(NSRange)range {
    self.URL = URL;
    self.offset = range.location;
    self.length = range.length;
    NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:URL];
    request.cachePolicy = NSURLRequestReloadIgnoringLocalAndRemoteCacheData;
    long long endOffset = self.offset + self.length - 1;
    NSString *httpRange = [NSString stringWithFormat:@"bytes=%lld-%lld",self.offset,endOffset];
    [request setValue:httpRange forHTTPHeaderField:@"Range"];
    NSURLSessionTask *task = [self.session dataTaskWithRequest:request];
    [task resume];
    self.isDownloading = YES;
    self.task = task;
    return task;
}

這樣,就會下載該音視頻指定位置長度的二進(jìn)制文件,而不是整部下載,所以這是一個(gè)分片下載器。
然后有SessionDelegate代理得到下載的結(jié)果:

#pragma mark - NSURLSessionDelegate

//開始接受數(shù)據(jù)
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
didReceiveResponse:(NSURLResponse *)response
 completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler {
    //設(shè)置contentInformation
    completionHandler(NSURLSessionResponseAllow);
    
}


- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
    if ([self.delegate respondsToSelector:@selector(downloader:didReceiveData:task:)]) {
        [self.delegate downloader:self didReceiveData:data task:dataTask];
    }
}


//下載完成
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
didCompleteWithError:(nullable NSError *)error {
    [KWLog kwLog:@"下載結(jié)束"];
    self.isDownloading = NO;
    if ([self.delegate respondsToSelector:@selector(downloader:didCompleteWithError:task:)]) {
        [self.delegate downloader:self didCompleteWithError:error task:task];
    }
}

關(guān)于contentInfomation
在上面didResponse開始返回?cái)?shù)據(jù)前,我們可以提取該資源的格式、長度信息:

KWHttpInfomation *info = [[KWHttpInfomation alloc] init];
    if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
        NSHTTPURLResponse *HTTPURLResponse = (NSHTTPURLResponse *)response;
        NSString *acceptRange = HTTPURLResponse.allHeaderFields[@"Accept-Ranges"];
        info.byteRangeAccessSupported = [acceptRange isEqualToString:@"bytes"];
        //考慮到絕大部分服務(wù)器都支持bytes,這里就全部設(shè)置為支持
        info.byteRangeAccessSupported = YES;
        info.contentLength = [[[HTTPURLResponse.allHeaderFields[@"Content-Range"]
                                componentsSeparatedByString:@"/"] lastObject] longLongValue];
        if (info.contentLength == 0) {
            info.contentLength = [HTTPURLResponse.allHeaderFields[@"Content-Length"] longLongValue];
        }
    }
    NSString *mimeType = response.MIMEType;
    CFStringRef contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType,
                                                                    (__bridge CFStringRef)(mimeType),
                                                                    NULL);
    info.contentType = CFBridgingRelease(contentType);
    if ([self.delegate respondsToSelector:@selector(downloader:informatiion:task:)]) {
        [self.delegate downloader:self informatiion:info task:dataTask];
    }
    [KWLog kwLog:@"%@",info.debugDescription];
    //緩存info
    [KWFileManager saveContentInfomation:info URL:dataTask.originalRequest.URL];

注意點(diǎn):
1.info.byteRangeAccessSupported 這里我本來是根據(jù)headerFields獲取是否支持分片下載,后面我發(fā)現(xiàn)很多視頻headerField并沒有指明是否支持分片下載,但是測試發(fā)現(xiàn),這些未指明的視頻都是可以分片下載的,然后我網(wǎng)上查了一下,發(fā)現(xiàn)基本95%以上的服務(wù)器是支持分片下載的,所以這里我全部默認(rèn)為可分片下載了。

2.獲取的頭信息由于以便于下次不用下載,所以這里要寫入本地緩存。

3.提供Task cancel功能,以便于外部可隨時(shí)取消該資源下載。

KWFileManager

該文件負(fù)責(zé)緩存的讀取、存儲、清理等功能。

由于是緩存,所以,我放在了Cache主目錄下,然后新建了音視頻根目錄:

+ (NSString *)cacheRootPath {
    //將下載默認(rèn)存放地址移到緩存目錄下
    NSString *cachePath = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory,
                                                               NSUserDomainMask, YES) lastObject];
    //緩存根目錄
    NSString *mediaPath = [cachePath stringByAppendingPathComponent:kMediaCachePath];
    return mediaPath;
}

1.將URL的Md5值作為某個(gè)資源的文件夾名稱
由于每個(gè)每個(gè)資源URL長度不一,考慮到Md5唯一性和長度一致的特性,所以講md5值作為key是非常合適的。

2.某個(gè)片段文件名以“startOffset_endOffset_sugguestName”命名

NSString *fileName = [NSString stringWithFormat:@"%lld_%lld_%@",offset,
                          endOffset,task.response.suggestedFilename];

這樣存儲的文件最終形態(tài)如下圖所示:

緩存目錄結(jié)構(gòu)

我是以每個(gè)下載請求的Range+name作為文件名,這樣文件名稱就包含了資源的位置長度,以便于下次快速檢索從緩存讀取的文件。

那么,由于下次請求的位置和長度不可能剛好和緩存中的一樣長,那么這里采取了2種方式:

  • 1.優(yōu)先單個(gè)碎片文件,取子集:
for (NSString *fileName in files) {
        NSArray *components = [fileName componentsSeparatedByString:@"_"];
        if (components.count >= 3) {
            NSString *offset = [components firstObject];
            NSString *endOffset = components[1];
            if(start == 0 && end == 1) {
                if (start == [offset longLongValue] &&
                    end == [endOffset longLongValue]) {
                    //落在本地某個(gè)片段內(nèi)
                    targetFileName = fileName;
                    break;
                }
            }else if (start >= [offset longLongValue] && end <= [endOffset longLongValue] &&
                      (start != 0 && end != 1)) {
                //落在本地某個(gè)片段內(nèi)
                targetFileName = fileName;
                break;
            }
        }
    }
  • 跨碎片拼接:有些碎片頭尾是可以連接成一個(gè)大的碎片的,然后再去子集:
//嘗試垮碎片查找
        NSString *firstFileName = [self fileNameOffset:start files:files];
        if (firstFileName) {
            NSRange firstRange = [self fileRange:firstFileName];
            if (firstRange.length != 0) {
                long long firstEnd = firstRange.location + firstRange.length - 1;
                NSMutableArray *sequentFiles = [NSMutableArray arrayWithObject:firstFileName];
                [self getNextSequentFileNameByLastEnd:firstEnd files:files output:&sequentFiles];
                if (sequentFiles.count > 1) {
                    NSString *lastFileName = [sequentFiles lastObject];
                    NSRange lastRange = [self fileRange:lastFileName];
                    if (lastRange.location + lastRange.length - 1 >= end) {
                        [readFiles addObjectsFromArray:sequentFiles];
                    }
                }
            }
        }

上面在查找下一個(gè)連續(xù)的碎片采取了遞歸方式:

+ (void)getNextSequentFileNameByLastEnd:(long long)lastEnd files:(NSArray *)files output:(NSMutableArray **)outputArray {
    NSString *findFileName = nil;
    for (NSString *fileName in files) {
        NSRange range = [self fileRange:fileName];
        if (range.length != 0) {
            if (range.location == lastEnd + 1) {
                findFileName = fileName;
                break;
            }
        }
    }
    if (findFileName) {
        NSRange fileRange = [self fileRange:findFileName];
        if (fileRange.length != 0) {
            long long end = fileRange.location + fileRange.length - 1;
            NSMutableArray *outPut = *outputArray;
            [outPut addObject:findFileName];
            [self getNextSequentFileNameByLastEnd:end files:files output:&outPut];
        }
    }
}

以上是緩存處理文件的主要難點(diǎn)。其他的文件寫入、刪除常規(guī)操作,故不再敘述。

以上就是我這個(gè)項(xiàng)目的主要細(xì)節(jié)。

項(xiàng)目評價(jià)

  • 優(yōu)點(diǎn)

1.實(shí)現(xiàn)了邊播放邊緩存資源

2.支持seek操作,可立即從新的進(jìn)度繼續(xù)播放,較流暢

3.支持片段緩存,而不是僅僅整體緩存,節(jié)省了用戶流量

4.支持緩存自定義清理,可以清理單個(gè)資源文件

  • 待優(yōu)化點(diǎn)

1.某些資源頻繁cancel請求的問題,當(dāng)?shù)谝淮斡|發(fā)了cancel請求后,第二次請求過程中,即便有部分緩存,視頻不會立即播放,必須等待本次片段全部請求完畢才開始播放。這個(gè)原因尚不明確,因?yàn)閞esouceLoader是個(gè)黑匣子,理論上,下載過程中是不必等到finishLoading完畢后才播放資源,只要resonseWithData:給緩沖區(qū)塞給了足夠可播放的data即可播放,但是第一次cancel后新價(jià)值的資源總是要帶到加載完畢才播放,用戶等待較長,但是一般出現(xiàn)該請求都是弱網(wǎng)絡(luò)產(chǎn)生,一般情況下流暢度不錯(cuò)。

2.緩存獲取我的方案理論上不是最優(yōu)解,因?yàn)槲覜]有考慮本地有部分緩存,另外部分需要下載的情況。但是,這中方案理論上會多出很多的請求次數(shù),增加了請求次數(shù)和復(fù)雜度,所以最終沒有采用,不排除后續(xù)會優(yōu)化這一處。

  • 總結(jié)

整體項(xiàng)目前后花了大約1星期的時(shí)間完成,由于以前對AVAssetResourceLoader并沒有接觸過,很多屬性和方法都是自己摸索著嘗試,通過大量的測試,基本上摸清了resouceLoaderDelegate的”脾氣“。

整體效果比較滿意,當(dāng)然,網(wǎng)上也研究了別的同學(xué)的方案,最終我還是采用了自己的緩存思路實(shí)現(xiàn),鍛煉了能力,雖然不是最優(yōu)解,但是經(jīng)過了大量優(yōu)化測試,bug較少,現(xiàn)在將項(xiàng)目開源出來,和大家一起分享,如果有覺得這個(gè)方案對自己有用,就給個(gè)star 吧,同時(shí)以熱烈歡迎對音視頻有興趣的同學(xué)和我留言,繼續(xù)探討更優(yōu)化的方案哦,謝謝~

KWResourceLoader

demo效果圖:

demo截圖
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容