Glide 中的DecodeJob 得工作其實(shí)是比較亂的,看的我真是不要不要的,真想說 read the f**king source code
今天繼續(xù)昨天的DecodeJob 來分析,
先來捋一捋整個(gè)圖片第一次加載流程,這里我們將它分為4步
1.根據(jù)當(dāng)前的url 寬 高 還有其他信息,去查找已經(jīng)在磁盤上面按照?qǐng)D片的寬高緩存好的圖片,并一層一層向上返回
2.如果按照寬高沒有找到,則嘗試尋找沒有寬高的原圖,如果存在原圖,那么將這個(gè)原圖按照寬高獲取,并一層一層向上返回
3.如果原圖也沒有,則去下載這個(gè)圖片,
4.圖片下載完后,根據(jù)是否可以在磁盤緩存,如果可以則執(zhí)行第2步,否則直接向上返回
工作流程已經(jīng)知道了,繼續(xù)看一下關(guān)鍵的類
EngineJob
EngineJob 可以作為橋梁通知上層接口,同時(shí)還可以作為線程選擇的調(diào)度
ResourceCacheGenerator
ResourceCacheGenerator 是一個(gè)根據(jù)指定的寬高去加載磁盤緩存的工具類,
DataCacheGenerator
DataCacheGenerator 是一個(gè)根據(jù)原圖去加載指定寬高的工具類
SourceGenerator
SourceGenerator是一個(gè)處理緩存和開啟下載圖片的工具類
DecodeHelper
DecodeHelper 是 一個(gè)幫助我們獲取一些信息和實(shí)施下載的工具類
知道了這些再去看代碼會(huì)輕松的很多,
class DecodeJob<R> implements DataFetcherGenerator.FetcherReadyCallback, Runnable,Comparable<DecodeJob<?>>,Poolable
先看一下DecodeJob 的類的繼承關(guān)系,需要注意的是他實(shí)現(xiàn)了Runnable 接口,這樣我們就知道改如何他看的內(nèi)部方法的執(zhí)行順序了,還是從上一篇的構(gòu)建DecodeJob開始分析
EngineJob<R> engineJob =
engineJobFactory.build(
key,
isMemoryCacheable,
useUnlimitedSourceExecutorPool,
useAnimationPool,
onlyRetrieveFromCache);
DecodeJob<R> decodeJob =
decodeJobFactory.build(
glideContext,
model,
key,
signature,
width,
height,
resourceClass,
transcodeClass,
priority,
diskCacheStrategy,
transformations,
isTransformationRequired,
isScaleOnlyOrNoTransform,
onlyRetrieveFromCache,
options,
engineJob);
jobs.put(key, engineJob);
engineJob.addCallback(cb, callbackExecutor);
engineJob.start(decodeJob);
從代碼上看到創(chuàng)建了一個(gè)DecodeJob ,并使用engineJob.start ,
EngineJob.start
public synchronized void start(DecodeJob<R> decodeJob) {
this.decodeJob = decodeJob;
GlideExecutor executor = decodeJob.willDecodeFromCache()
? diskCacheExecutor
: getActiveSourceExecutor();
executor.execute(decodeJob);
}
看到他做了一個(gè)判斷,用來選擇線程池來執(zhí)行DecodeJob ,從字面的意思應(yīng)該是配置有關(guān)系,意思應(yīng)該是是否可以從緩存解碼,如果可以則使用磁盤緩存線程池,否則選擇下載線程池,既然是執(zhí)行DecodeJob 這個(gè)Runnable ,那么直接去看一下他的Runnable
DecodeJob.run
@Override
public void run() {
// This should be much more fine grained, but since Java's thread pool implementation silently
// swallows all otherwise fatal exceptions, this will at least make it obvious to developers
// that something is failing.
GlideTrace.beginSectionFormat("DecodeJob#run(model=%s)", model);
// Methods in the try statement can invalidate currentFetcher, so set a local variable here to
// ensure that the fetcher is cleaned up either way.
DataFetcher<?> localFetcher = currentFetcher;
try {
if (isCancelled) {
notifyFailed();
return;
}
runWrapped();
} catch (CallbackException e) {
// If a callback not controlled by Glide throws an exception, we should avoid the Glide
// specific debug logic below.
throw e;
} catch (Throwable t) {
// Catch Throwable and not Exception to handle OOMs. Throwables are swallowed by our
// usage of .submit() in GlideExecutor so we're not silently hiding crashes by doing this. We
// are however ensuring that our callbacks are always notified when a load fails. Without this
// notification, uncaught throwables never notify the corresponding callbacks, which can cause
// loads to silently hang forever, a case that's especially bad for users using Futures on
// background threads.
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "DecodeJob threw unexpectedly"
+ ", isCancelled: " + isCancelled
+ ", stage: " + stage, t);
}
// When we're encoding we've already notified our callback and it isn't safe to do so again.
if (stage != Stage.ENCODE) {
throwables.add(t);
notifyFailed();
}
if (!isCancelled) {
throw t;
}
throw t;
} finally {
// Keeping track of the fetcher here and calling cleanup is excessively paranoid, we call
// close in all cases anyway.
if (localFetcher != null) {
localFetcher.cleanup();
}
GlideTrace.endSection();
}
}
在run方法中先判斷了是否已經(jīng)取消此次請(qǐng)求,其實(shí)我在最開始閱讀的時(shí)候在這里就有一個(gè)疑問,既然加載圖片,那么在開始加載前就判斷是否取消有什么意義,難道寫代碼的時(shí)候Glide.with(context).load(url).into(imageView),然后下一行代碼直接取消?否則最開始判斷取消的意義又在哪里,這個(gè)問題我們先記一下,后面我們?cè)诜治龃a的過程中會(huì)解答
繼續(xù)分析run 方法,判斷了是否取消后就執(zhí)行了 runWrapped(); 方法
不過這里我們需要定義一下,此次加載是我么首次加載這張圖片,不存在內(nèi)存緩存,也不存在磁盤緩存,
DecodeJob.runWrapped
private void runWrapped() {
switch (runReason) {
case INITIALIZE:
stage = getNextStage(Stage.INITIALIZE);
currentGenerator = getNextGenerator();
runGenerators();
break;
case SWITCH_TO_SOURCE_SERVICE:
runGenerators();
break;
case DECODE_DATA:
decodeFromRetrievedData();
break;
default:
throw new IllegalStateException("Unrecognized run reason: " + runReason);
}
}
可以看到他是根據(jù)狀態(tài)來判斷執(zhí)行的哪一個(gè)方法,DecodeJob 在初始化的時(shí)候?qū)顟B(tài)設(shè)置為INITIALIZE,那么此時(shí)我們就INITIALIZE分支,記住現(xiàn)在的狀態(tài)
狀態(tài) INITIALIZE狀態(tài)
stage = getNextStage(Stage.INITIALIZE);
currentGenerator = getNextGenerator();
runGenerators();
在INITIALIZE 狀態(tài)下執(zhí)行了上面的方法 ,去看getNextStage(Stage.INITIALIZE);
private Stage getNextStage(Stage current) {
switch (current) {
case INITIALIZE:
return diskCacheStrategy.decodeCachedResource()
? Stage.RESOURCE_CACHE : getNextStage(Stage.RESOURCE_CACHE);
case RESOURCE_CACHE:
return diskCacheStrategy.decodeCachedData()
? Stage.DATA_CACHE : getNextStage(Stage.DATA_CACHE);
case DATA_CACHE:
// Skip loading from source if the user opted to only retrieve the resource from cache.
return onlyRetrieveFromCache ? Stage.FINISHED : Stage.SOURCE;
case SOURCE:
case FINISHED:
return Stage.FINISHED;
default:
throw new IllegalArgumentException("Unrecognized stage: " + current);
}
}
在這個(gè)根據(jù) diskCacheStrategy 來判斷是否使用磁盤緩存寬高緩存,這個(gè)是在構(gòu)建Glide時(shí)配置的緩存設(shè)置,默認(rèn)使用DiskCacheStrategy.AUTOMATIC ,返回的是true
DiskCacheStrategy.AUTOMATIC
public static final DiskCacheStrategy AUTOMATIC = new DiskCacheStrategy() {
@Override
public boolean isDataCacheable(DataSource dataSource) {
return dataSource == DataSource.REMOTE;
}
@Override
public boolean isResourceCacheable(boolean isFromAlternateCacheKey, DataSource dataSource,
EncodeStrategy encodeStrategy) {
return ((isFromAlternateCacheKey && dataSource == DataSource.DATA_DISK_CACHE)
|| dataSource == DataSource.LOCAL)
&& encodeStrategy == EncodeStrategy.TRANSFORMED;
}
@Override
public boolean decodeCachedResource() {
return true;
}
@Override
public boolean decodeCachedData() {
return true;
}
};
此時(shí)狀態(tài)變更為RESOURCE_CACHE ,繼續(xù)執(zhí)行g(shù)etNextGenerator 方法
狀態(tài) RESOURCE_CACHE
DecodeJob.getNextGenerator
private DataFetcherGenerator getNextGenerator() {
switch (stage) {
case RESOURCE_CACHE:
return new ResourceCacheGenerator(decodeHelper, this);
case DATA_CACHE:
return new DataCacheGenerator(decodeHelper, this);
case SOURCE:
return new SourceGenerator(decodeHelper, this);
case FINISHED:
return null;
default:
throw new IllegalStateException("Unrecognized stage: " + stage);
}
}
返回的是一個(gè)ResourceCacheGenerator,并將它賦值給currentGenerator ,然后執(zhí)行runGenerators方法
DecodeJob.runGenerators
private void runGenerators() {
currentThread = Thread.currentThread();
startFetchTime = LogTime.getLogTime();
boolean isStarted = false;
while (!isCancelled && currentGenerator != null
&& !(isStarted = currentGenerator.startNext())) {
stage = getNextStage(stage);
currentGenerator = getNextGenerator();
if (stage == Stage.SOURCE) {
reschedule();
return;
}
}
// We've run out of stages and generators, give up.
if ((stage == Stage.FINISHED || isCancelled) && !isStarted) {
notifyFailed();
}
// Otherwise a generator started a new load and we expect to be called back in
// onDataFetcherReady.
}
這里的判斷就比較抽象了,
此時(shí)就執(zhí)行了我們上面所描述的第1步
沒有被取消,currentGenerator 不為空,在getNextGenerator,我們得到的了一個(gè)ResourceCacheGenerator,所以這個(gè)條件也成立,currentGenerator.startNext() 的意思就是是否找到有相應(yīng)寬高的磁盤緩存,首次加載肯定是沒有的,那么這個(gè)條件的否定命題也是成立的,
執(zhí)行完第1步后更改狀態(tài),執(zhí)行第2步
當(dāng)前狀態(tài)RESOURCE_CACHE,此時(shí)就會(huì)再次更換狀態(tài)stage = getNextStage(stage);將狀態(tài)變更為DATA_CACHE,同時(shí)也會(huì)判斷是否可以保存原圖,在默認(rèn)的DiskCacheStrategy.AUTOMATIC 配置中是true,此時(shí) currentGenerator = getNextGenerator();返回了一個(gè)DataCacheGenerator,由于是第一次加載所以這里currentGenerator.startNext 返回同樣是false,繼續(xù)執(zhí)行whild循環(huán)
同理執(zhí)行第3步,
當(dāng)前狀態(tài)為DATA_CACHE,我們同樣還是通過stage = getNextStage(stage);將狀態(tài)變更為SOURCE,將currentGenerator變更為SourceGenerator,此時(shí)
if (stage == Stage.SOURCE) {
reschedule();
return;
}
當(dāng)前狀態(tài)變更到了SOURCE,跳出循環(huán),執(zhí)行reschedule
DecodeJob.reschedule
@Override
public void reschedule() {
runReason = RunReason.SWITCH_TO_SOURCE_SERVICE;
callback.reschedule(this);
}
在reschedule 方法中將runReason 變更為 SWITCH_TO_SOURCE_SERVICE,同時(shí)調(diào)用callback.reschedule 方法,在DecodeJob的初始化方法中發(fā)現(xiàn)這個(gè)callback就是 EngineJob,我去看一下他的reschedule方法
EngineJob.reschedule
@Override
public void reschedule(DecodeJob<?> job) {
// Even if the job is cancelled here, it still needs to be scheduled so that it can clean itself
// up.
getActiveSourceExecutor().execute(job);
}
這里就比較簡(jiǎn)單了,重新選擇一個(gè)線程池來執(zhí)行DecodeJob 這個(gè)Runnable,同樣也解釋了我們?cè)谏厦鏇]有說清楚兩個(gè)問題,
1.在engineJob.start(decodeJob);中我們說他是根據(jù)一個(gè)配置來選擇所需的線程池,從上面的代碼其實(shí)是可以理解的,磁盤緩存和下載是在不同的線程池中工作的,
2.那就是為什么在run 方法的最開始就判斷是否已經(jīng)取消加載圖片,原因就是存在線程的調(diào)度,這個(gè)run方法不一定只執(zhí)行一次
調(diào)用完線程后,將會(huì)重新執(zhí)行DecodeJob.run 方法,我們先標(biāo)記一下狀態(tài)
runReason = RunReason.SWITCH_TO_SOURCE_SERVICE;
stage=SOURCE
currentGenerator=SourceGenerator
private void runWrapped() {
switch (runReason) {
case INITIALIZE:
stage = getNextStage(Stage.INITIALIZE);
currentGenerator = getNextGenerator();
runGenerators();
break;
case SWITCH_TO_SOURCE_SERVICE:
runGenerators();
break;
case DECODE_DATA:
decodeFromRetrievedData();
break;
default:
throw new IllegalStateException("Unrecognized run reason: " + runReason);
}
}
此時(shí)再執(zhí)行run方法后 就會(huì)執(zhí)行runWrapped 的SWITCH_TO_SOURCE_SERVICE 分支,執(zhí)行 runGenerators 方法,此時(shí)就會(huì)執(zhí)行SourceGenerator的下載方法,看到這里想必整個(gè)加載的流程大家心里面已經(jīng)有一個(gè)差不多的概念了,
這個(gè)里面涉及到了三個(gè)關(guān)鍵的類 ResourceCacheGenerator DataCacheGenerator SourceGenerator 他們的具體工作流程,我們來看一下,主要是看他們的startNext 的方法
ResourceCacheGenerator.startNext
@Override
public boolean startNext() {
List<Key> sourceIds = helper.getCacheKeys();
if (sourceIds.isEmpty()) {
return false;
}
List<Class<?>> resourceClasses = helper.getRegisteredResourceClasses();
if (resourceClasses.isEmpty()) {
if (File.class.equals(helper.getTranscodeClass())) {
return false;
}
throw new IllegalStateException(
"Failed to find any load path from " + helper.getModelClass() + " to "
+ helper.getTranscodeClass());
}
while (modelLoaders == null || !hasNextModelLoader()) {
resourceClassIndex++;
if (resourceClassIndex >= resourceClasses.size()) {
sourceIdIndex++;
if (sourceIdIndex >= sourceIds.size()) {
return false;
}
resourceClassIndex = 0;
}
Key sourceId = sourceIds.get(sourceIdIndex);
Class<?> resourceClass = resourceClasses.get(resourceClassIndex);
Transformation<?> transformation = helper.getTransformation(resourceClass);
// PMD.AvoidInstantiatingObjectsInLoops Each iteration is comparatively expensive anyway,
// we only run until the first one succeeds, the loop runs for only a limited
// number of iterations on the order of 10-20 in the worst case.
currentKey =
new ResourceCacheKey(// NOPMD AvoidInstantiatingObjectsInLoops
helper.getArrayPool(),
sourceId,
helper.getSignature(),
helper.getWidth(),
helper.getHeight(),
transformation,
resourceClass,
helper.getOptions());
cacheFile = helper.getDiskCache().get(currentKey);
if (cacheFile != null) {
sourceKey = sourceId;
modelLoaders = helper.getModelLoaders(cacheFile);
modelLoaderIndex = 0;
}
}
loadData = null;
boolean started = false;
while (!started && hasNextModelLoader()) {
ModelLoader<File, ?> modelLoader = modelLoaders.get(modelLoaderIndex++);
loadData = modelLoader.buildLoadData(cacheFile,
helper.getWidth(), helper.getHeight(), helper.getOptions());
if (loadData != null && helper.hasLoadPath(loadData.fetcher.getDataClass())) {
started = true;
loadData.fetcher.loadData(helper.getPriority(), this);
}
}
return started;
}
關(guān)于ResourceCacheGenerator 這個(gè)類的表述,其中還涉及到DecodeHelper的一些使用,我也是從網(wǎng)上看了一些文章,https://blog.csdn.net/nbsp22/article/details/80651648 這一篇文章講的就非常不錯(cuò),還有這個(gè)邏輯雖然我能看懂,但是讓我描述出來就有點(diǎn)繞了,我也從他的文章中copy 一下他的描述
此時(shí)通過decodeHelper拿到的sourceIds就是[GlideUrl,ObjectKey]荣暮,如果通過注冊(cè)的信息找不到此時(shí)的key,表明glide本身還不支持這種方式,因此調(diào)用結(jié)束毕荐。顯然此時(shí)是支持的告匠,接下來是通過helper的getRegisteredResourceClasses獲取resourceClass信息易猫,這里大致是glide所支持的資源類信息歌懒,也就是能夠進(jìn)行decode的昼伴。這里它存放的是[GifDrawable,Bitmap,BitmapDrawable]匾旭。因此接下來進(jìn)入第一個(gè)的while循環(huán):
1.由resourceClasses和sourceIds組成的一個(gè)正交關(guān)系,迭代每一組圃郊。
2.迭代開始前价涝,若modelLoaders為空或者size為0,則繼續(xù)迭代進(jìn)入步驟3持舆,否則循環(huán)結(jié)束色瘩。
3/循環(huán)中,檢測(cè)是否已經(jīng)全部迭代完成逸寓,如果還有居兆,則進(jìn)入步驟4,否則循環(huán)結(jié)束竹伸。
4.對(duì)每一組泥栖,獲取相應(yīng)的緩存Key對(duì)象,根據(jù)緩存key去diskcache中查找緩存文件勋篓,查找成功吧享,則通過getModelLoaders獲取當(dāng)前的modelLoaders信息,繼續(xù)執(zhí)行循環(huán)譬嚣,進(jìn)入步驟2钢颂。
從這里我們可以看出這個(gè)while循環(huán)的作用就是找到modelLoaders信息,如果沒找到有效的拜银,則循環(huán)結(jié)束殊鞭,方法塊正交組迭代完成之后,startNext方法結(jié)束盐股,方法返回false钱豁,交給下一個(gè)Generator去處理。如果能夠找到疯汁,則執(zhí)行下一個(gè)while循環(huán)牲尺。這個(gè)循環(huán)相對(duì)簡(jiǎn)單一些,就是根據(jù)上一個(gè)while循環(huán)查找到的modelLoaders幌蚊,進(jìn)行遍歷谤碳,只要有一個(gè)對(duì)應(yīng)的fetcher能夠處理,則startNext返回true溢豆,表明此時(shí)這個(gè)generator已經(jīng)能夠處理本次請(qǐng)求蜒简,所以也不會(huì)再交給其他的generator對(duì)應(yīng)的fetcher去處理了。
在我們此時(shí)的情景中漩仙,ResourceCacheGenerator是無法處理本次請(qǐng)求的搓茬,所以犹赖,交給下一個(gè)Generator去處理,也就是DataCacheGenerator的startNext卷仑。
DataCacheGenerator .startNext
@Override
public boolean startNext() {
while (modelLoaders == null || !hasNextModelLoader()) {
sourceIdIndex++;
if (sourceIdIndex >= cacheKeys.size()) {
return false;
}
Key sourceId = cacheKeys.get(sourceIdIndex);
// PMD.AvoidInstantiatingObjectsInLoops The loop iterates a limited number of times
// and the actions it performs are much more expensive than a single allocation.
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
Key originalKey = new DataCacheKey(sourceId, helper.getSignature());
cacheFile = helper.getDiskCache().get(originalKey);
if (cacheFile != null) {
this.sourceKey = sourceId;
modelLoaders = helper.getModelLoaders(cacheFile);
modelLoaderIndex = 0;
}
}
loadData = null;
boolean started = false;
while (!started && hasNextModelLoader()) {
ModelLoader<File, ?> modelLoader = modelLoaders.get(modelLoaderIndex++);
loadData =
modelLoader.buildLoadData(cacheFile, helper.getWidth(), helper.getHeight(),
helper.getOptions());
if (loadData != null && helper.hasLoadPath(loadData.fetcher.getDataClass())) {
started = true;
loadData.fetcher.loadData(helper.getPriority(), this);
}
}
return started;
}
如果你看了上面關(guān)于ResourceCacheGenerator的描述,那么再看這里就比較簡(jiǎn)單了,我們來說一下他們之間有什么不同,在ResourceCacheGenerator 與 DataCacheGenerator 都是利用key 從緩存中匹配的,但是在ResourceCacheGenerator 中的key的創(chuàng)建利用了寬高而DataCacheGenerator 中則沒有,可見ResourceCacheGenerator 是獲取的是響應(yīng)寬高的資源,而DataCacheGenerator 是獲取的原圖,在獲取原圖成功后再根據(jù)寬高去獲取相應(yīng)數(shù)據(jù),
第一次加載這里肯定也是沒有的,繼續(xù)去下載
SourceGenerator .startNext
@Override
public boolean startNext() {
if (dataToCache != null) {
Object data = dataToCache;
dataToCache = null;
cacheData(data);
}
if (sourceCacheGenerator != null && sourceCacheGenerator.startNext()) {
return true;
}
sourceCacheGenerator = null;
loadData = null;
boolean started = false;
while (!started && hasNextModelLoader()) {
loadData = helper.getLoadData().get(loadDataListIndex++);
if (loadData != null
&& (helper.getDiskCacheStrategy().isDataCacheable(loadData.fetcher.getDataSource())
|| helper.hasLoadPath(loadData.fetcher.getDataClass()))) {
started = true;
loadData.fetcher.loadData(helper.getPriority(), this);
}
}
return started;
}
首次執(zhí)行 startNext 方法中的dataToCache 肯定是null,所有不會(huì)走加載緩存的方法,而是進(jìn)入while中執(zhí)行循環(huán)下載圖片,具體的下載過程是在loadData.fetcher.loadData(helper.getPriority(), this);這個(gè)方法中執(zhí)行的,我們就不分析了,來說一下下載后他干了什么,下載成功后會(huì)回調(diào)onDataReady 這個(gè)方法
SourceGenerator.onDataReady
@Override
public void onDataReady(Object data) {
DiskCacheStrategy diskCacheStrategy = helper.getDiskCacheStrategy();
if (data != null && diskCacheStrategy.isDataCacheable(loadData.fetcher.getDataSource())) {
dataToCache = data;
// We might be being called back on someone else's thread. Before doing anything, we should
// reschedule to get back onto Glide's thread.
cb.reschedule();
} else {
cb.onDataFetcherReady(loadData.sourceKey, data, loadData.fetcher,
loadData.fetcher.getDataSource(), originalKey);
}
}
判斷了一下是否可以磁盤換粗,如果可以將dataToCache 設(shè)置為data用于數(shù)據(jù)緩存,繼續(xù)執(zhí)行線程調(diào)度,我們?cè)谏厦娣治鲞^了,如果線程調(diào)度之后肯定還是會(huì)走SourceGenerator.startNext 方法,此時(shí)dataToCache 不為null,
SourceGenerator.startNext
if (dataToCache != null) {
Object data = dataToCache;
dataToCache = null;
cacheData(data);
}
if (sourceCacheGenerator != null && sourceCacheGenerator.startNext()) {
return true;
}
這里是走了緩存數(shù)據(jù),然后創(chuàng)建一個(gè)DataCacheGenerator(原始圖片解碼器) 去執(zhí)行他的解碼,
SourceGenerator.cacheData
private void cacheData(Object dataToCache) {
long startTime = LogTime.getLogTime();
try {
Encoder<Object> encoder = helper.getSourceEncoder(dataToCache);
DataCacheWriter<Object> writer =
new DataCacheWriter<>(encoder, dataToCache, helper.getOptions());
originalKey = new DataCacheKey(loadData.sourceKey, helper.getSignature());
helper.getDiskCache().put(originalKey, writer);
if (Log.isLoggable(TAG, Log.VERBOSE)) {
Log.v(TAG, "Finished encoding source to cache"
+ ", key: " + originalKey
+ ", data: " + dataToCache
+ ", encoder: " + encoder
+ ", duration: " + LogTime.getElapsedMillis(startTime));
}
} finally {
loadData.fetcher.cleanup();
}
sourceCacheGenerator =
new DataCacheGenerator(Collections.singletonList(loadData.sourceKey), helper, this);
}
到這里整個(gè)過程就結(jié)束了