問題: Gif読み込みのためのグライド最適化のアイデア
日付: 2020-07-26 10:08
カテゴリ: NDK
タグ: グライド
Glide 4 に基づいています。.9.0バージョン解析
はじめに
Glideイメージフレームワークは、直接GIFイメージをロードすることができますが、銀行協力プロジェクトを行うには、ページを出荷する必要があるため、GIFイメージをロードする必要がありますが、Glideフレームワークの使用で見つかったGIFイメージをロードするには、明らかに遅延があることがわかりました。
Gifイメージを読み込むグライドのソースコードをチェックした後に知っている:Gifイメージフレームの読み込みでグライドは、前のフレームのレンダリングと次のフレームの準備がシリアルであり、このプロセスは、次のフレームの準備がGif間隔の再生時間以上である場合、それは再生の遅れが発生します。また、このプロセスは、StandardGifDecoderは、前のフレームのデータを保持するだけで、フレームを描画するために現在の必要性を取得するたびに新しいBitmap(これは新しいBitmapオブジェクトであることに注意してください)を取得するBitmapPoolからされるので、Gifをロードするプロセスは、Glideはまた、メモリ消費量が高くなるにつながる少なくとも2つのBitmapが必要です。これはまた、高いメモリ消費につながります。
ここでは、GlideがGifを読み込む方法と、ラグの最適化方法について説明します。
GlideGif の読み込み原理
この記事は以下のキーワードを中心に書かれています。
- Glide
- StreamGifDecoder
- ByteBufferGifDecoder
- StandardGifDecoder
- GifDrawable
1)まず、Gif関連のデコーダーを紹介します。
Gifに関する情報はGlideの構成にあります。
Glide(
@NonNull Context context,
/*.....*/) {
//...
List<ImageHeaderParser> imageHeaderParsers = registry.getImageHeaderParsers();
//..
GifDrawableBytesTranscoder gifDrawableBytesTranscoder = new GifDrawableBytesTranscoder();
//...
registry
//...
/* GIFs */
.append(
Registry.BUCKET_GIF,
InputStream.class,
GifDrawable.class,
new StreamGifDecoder(imageHeaderParsers, byteBufferGifDecoder, arrayPool))
.append(Registry.BUCKET_GIF, ByteBuffer.class, GifDrawable.class, byteBufferGifDecoder)
.append(GifDrawable.class, new GifDrawableEncoder())
/* GIF Frames */
// Compilation with Gradle requires the type to be specified for UnitModelLoader here.
.append(
GifDecoder.class, GifDecoder.class, UnitModelLoader.Factory.<GifDecoder>getInstance())
.append(
Registry.BUCKET_BITMAP,
GifDecoder.class,
Bitmap.class,
new GifFrameResourceDecoder(bitmapPool))
//...
.register(GifDrawable.class, byte[].class, gifDrawableBytesTranscoder);
ImageViewTargetFactory imageViewTargetFactory = new ImageViewTargetFactory();
//....
}
public class StreamGifDecoder implements ResourceDecoder<InputStream, GifDrawable> {
@Override
public Resource<GifDrawable> decode(@NonNull InputStream source, int width, int height,
@NonNull Options options) throws IOException {
// 1. InputStreamストリームをバイト配列として受け取る。
byte[] data = inputStreamToBytes(source);
if (data == null) {
return null;
}
// 2.ByteBufferラッパーを使って生のデータストリームを処理する,
//なぜByteBufferなのか考えてみよう。?
/**
@link StandardGifDecoder#setData();
// Initialize the raw data buffer.
rawData = buffer.asReadOnlyBuffer();
rawData.position(0);
rawData.order(ByteOrder.LITTLE_ENDIAN); // スモール・エンド・アライメント.低いものから高いものへのソート
*/
ByteBuffer byteBuffer = ByteBuffer.wrap(data);
return byteBufferDecoder.decode(byteBuffer, width, height, options);
}
}
詳細は以下の通り。
- バイト[] 配列は InputStream
- 処理後のbyte[]は、次の処理のためにByteBufferGifDecoderに渡されます。
public class ByteBufferGifDecoder implements ResourceDecoder<ByteBuffer, GifDrawable> {
//...
@Override
public GifDrawableResource decode(@NonNull ByteBuffer source, int width, int height,
@NonNull Options options) {
final GifHeaderParser parser = parserPool.obtain(source);
try {
return decode(source, width, height, parser, options);
} finally {
parserPool.release(parser);
}
}
@Nullable
private GifDrawableResource decode(
ByteBuffer byteBuffer, int width, int height, GifHeaderParser parser, Options options) {
long startTime = LogTime.getLogTime();
try {
// 1.GIFヘッダー情報を取得する
final GifHeader header = parser.parseHeader();
if (header.getNumFrames() <= 0 || header.getStatus() != GifDecoder.STATUS_OK) {
// If we couldn't decode the GIF, we will end up with a frame count of 0.
return null;
}
//2. GIFの背景が透明チャンネルを持つかどうかに応じて、Bitmapのタイプを決定する。
Bitmap.Config config = options.get(GifOptions.DECODE_FORMAT) == DecodeFormat.PREFER_RGB_565
? Bitmap.Config.RGB_565 : Bitmap.Config.ARGB_8888;
//3.ビットマップのサンプリングレートを計算する
int sampleSize = getSampleSize(header, width, height);
//4. Gifデータを取得するStandardGifDecoder。====> 静的な内部クラスGifDecoderFactoryによって
GifDecoder gifDecoder = gifDecoderFactory.build(provider, header, byteBuffer, sampleSize);
gifDecoder.setDefaultBitmapConfig(config);
gifDecoder.advance();
//5.Gifの次のフレームを取得する
Bitmap firstFrame = gifDecoder.getNextFrame();
if (firstFrame == null) {
return null;
}
Transformation<Bitmap> unitTransformation = UnitTransformation.get();
//6.GifDrawableはGifフレームから構築され、GIFフレームの再生に使用される。
GifDrawable gifDrawable =
new GifDrawable(context, gifDecoder, unitTransformation, width, height, firstFrame);
//7. GifDrawableはGifDrawableResourceにラップされ、GifDrawableのリサイクルを維持し、アニメーションの再生を停止するために使用される。.
return new GifDrawableResource(gifDrawable);
} finally {
if (Log.isLoggable(TAG, Log.VERBOSE)) {
Log.v(TAG, "Decoded GIF from stream in " + LogTime.getElapsedMillis(startTime));
}
}
}
}
@VisibleForTesting
static class GifDecoderFactory {
GifDecoder build(GifDecoder.BitmapProvider provider, GifHeader header,
ByteBuffer data, int sampleSize) {
//Gifフレームを読み込み、外部用にBitmapとして描画する標準的なGifデコーダーを入手する。
return new StandardGifDecoder(provider, header, data, sampleSize);
}
}
少し要約すると
- まず、ByteBufferDecoderによってGifのヘッダー情報を抽出します。
- ヘッダ情報に基づいて Gif の背景色を取得し、Bitmap の Config オプションを設定します。
- まだヘッダー情報からサンプリングレートを計算しています
- GIF デコーダ StandardGifDecoder を使用して GIF フレームを作成し、Bitmap として出力します。
- GifDrawableの構築(Gifアニメ再生用)
- GifDrawableResource の構築
GifイメージフレームがどのようにBitmapにデコードされるか、StandardGifDecoderを見てみましょう。
public class StandardGifDecoder implements GifDecoder {
private static final String TAG = StandardGifDecoder.class.getSimpleName();
//...
// ByteBufferGifDecoderデコード・メソッドによって、StandardGifDecoderを通してGifデータの次のフレームを取得し、Bitmapに変換するために使用されることがわかる。.
@Nullable
@Override
public synchronized Bitmap getNextFrame() {
//...
// Gifのヘッダー情報に従って、GIFの現在のフレームのフレームデータを取得する。
GifFrame currentFrame = header.frames.get(framePointer);
GifFrame previousFrame = null;
int previousIndex = framePointer - 1;
if (previousIndex >= 0) {
previousFrame = header.frames.get(previousIndex);
}
// Set the appropriate color table.
// カラーテーブルを設定する:ピクセルの透明度を設定するために使用される lct == local color table ; gct == global color table;ここでのアイデアは、グローバルなフレームよりもローカルなフレームを優先することだ。
act = currentFrame.lct != null ? currentFrame.lct : header.gct;
if (act == null) {
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "No valid color table found for frame #" + framePointer);
}
// No color table defined.
status = STATUS_FORMAT_ERROR;
return null;
}
// Reset the transparent pixel in the color table
// カラーテーブルのピクセルの透明度をリセットする
if (currentFrame.transparency) {
// Prepare local copy of color table ("pct = act"), see #1068
System.arraycopy(act, 0, pct, 0, act.length);
// Forget about act reference from shared header object, use copied version
act = pct;
// Set transparent color if specified.
// デフォルトは黒の透明である。
act[currentFrame.transIndex] = COLOR_TRANSPARENT_BLACK;
}
// Transfer pixel data to image.
// ピクセル・データをイメージに変換する
return setPixels(currentFrame, previousFrame);
}
//...
private Bitmap setPixels(GifFrame currentFrame, GifFrame previousFrame) {
// Final location of blended pixels.
// 前のフレームのBitmapピクセルデータを格納する。
final int[] dest = mainScratch;
// clear all pixels when meet first frame and drop prev image from last loop
if (previousFrame == null) {
if (previousImage != null) {
// 前のフレームのビットマップを再利用する
bitmapProvider.release(previousImage);
}
previousImage = null;
// そして、ビットマップのピクセルを黒で塗りつぶす。
Arrays.fill(dest, COLOR_TRANSPARENT_BLACK);
}
if (previousFrame != null && previousFrame.dispose == DISPOSAL_PREVIOUS
&& previousImage == null) {
//前のフレームは破棄され、クリアされる。
Arrays.fill(dest, COLOR_TRANSPARENT_BLACK);
}
// fill in starting image contents based on last image's dispose code
//1. 前フレームのデータをdest配列に注入する。
if (previousFrame != null && previousFrame.dispose > DISPOSAL_UNSPECIFIED) {
if (previousFrame.dispose == DISPOSAL_BACKGROUND) {
// Start with a canvas filled with the background color
@ColorInt int c = COLOR_TRANSPARENT_BLACK;
if (!currentFrame.transparency) {
c = header.bgColor;
if (currentFrame.lct != null && header.bgIndex == currentFrame.transIndex) {
c = COLOR_TRANSPARENT_BLACK;
}
} else if (framePointer == 0) {
isFirstFrameTransparent = true;
}
// The area used by the graphic must be restored to the background color.
int downsampledIH = previousFrame.ih / sampleSize;
int downsampledIY = previousFrame.iy / sampleSize;
int downsampledIW = previousFrame.iw / sampleSize;
int downsampledIX = previousFrame.ix / sampleSize;
int topLeft = downsampledIY * downsampledWidth + downsampledIX;
int bottomLeft = topLeft + downsampledIH * downsampledWidth;
for (int left = topLeft; left < bottomLeft; left += downsampledWidth) {
int right = left + downsampledIW;
for (int pointer = left; pointer < right; pointer++) {
dest[pointer] = c;
}
}
} else if (previousFrame.dispose == DISPOSAL_PREVIOUS && previousImage != null) {
// Start with the previous frame
// 前のフレームのBitmapからデータを取得し、それをdestに更新する。.
previousImage.getPixels(dest, 0, downsampledWidth, 0, 0, downsampledWidth,
downsampledHeight);
}
}
// Decode pixels for this frame into the global pixels[] scratch.
// 2. 現在のフレームをdestに解析する
decodeBitmapData(currentFrame);
if (currentFrame.interlace || sampleSize != 1) {
copyCopyIntoScratchRobust(currentFrame);
} else {
copyIntoScratchFast(currentFrame);
}
// Copy pixels into previous image
//3.現在のフレームのデータ dest を取得し、それを前のフレームのイメージに格納する。.
if (savePrevious && (currentFrame.dispose == DISPOSAL_UNSPECIFIED
|| currentFrame.dispose == DISPOSAL_NONE)) {
if (previousImage == null) {
previousImage = getNextBitmap();
}
previousImage.setPixels(dest, 0, downsampledWidth, 0, 0, downsampledWidth,
downsampledHeight);
}
// Set pixels for current image.
// 4.新しいBitmapを取得し、destからBitmapにデータをコピーし、GifDrawableに提供する。.
Bitmap result = getNextBitmap();
result.setPixels(dest, 0, downsampledWidth, 0, 0, downsampledWidth, downsampledHeight);
return result;
}
}
上記のコードの流れを見て、十分に直感的ではありませんが、次のマップを描画し、分析を容易にするために比較しました。
上図より
- 前のフレームの Bitmap からフレームデータを取得し、dest 配列に入力します。
- 次に、この配列からフレームデータを取得し、Bitmapに埋めます。
- 現在のフレームのデータを dest 配列にパースし、preBitmap に保存します。
- BitmapProvider(Bitmapの再利用を提供する)から新しいBitmapを取得し、現在のフレームの解析済みdest配列を外部用のBitmapにコピーします。
3) GifDrawableの助けを借りてGIFアニメーションを再生するグライド
public class GifDrawable extends Drawable implements GifFrameLoader.FrameCallback,
Animatable, Animatable2Compat {
@Override
public void start() {
isStarted = true;
resetLoopCount();
if (isVisible) {
startRunning();
}
}
private void startRunning() {
......
if (state.frameLoader.getFrameCount() == 1) {
invalidateSelf();
} else if (!isRunning) {
isRunning = true;
// 1. GifFrameLoaderのsubscribeメソッドが呼ばれる。
state.frameLoader.subscribe(this);
invalidateSelf();
}
}
@Override
public void onFrameReady() {
......
// 2. 描画の実行
invalidateSelf();
......
}
}
インターフェイスの実装からGifDrawableを見ることができる、それはアニメータブルDrawableですので、GifDrawableは、GIFアニメーションの再生をサポートすることができます重要なクラスがあるGifFrameLoaderは、GifDrawable GIFアニメーションの再生スケジューリングを支援するために使用されます。
GifDrawableのstartメソッドはアニメーション開始のエントリーポイントで、このメソッドではGifFrameLoaderに登録されたオブザーバーとしてのGifDrawableが描画のトリガーを引くと、onFrameReadyメソッドを呼び出します。その後、invalidateSelf を呼び出すことで描画が実行されます。
GifFrameLoader がどのようにアニメーションのスケジューリングを行うかを見てみましょう。
class GifFrameLoader {
//..
public interface FrameCallback {
void onFrameReady();
}
//..
void subscribe(FrameCallback frameCallback) {
if (isCleared) {
throw new IllegalStateException("Cannot subscribe to a cleared frame loader");
}
if (callbacks.contains(frameCallback)) {
throw new IllegalStateException("Cannot subscribe twice in a row");
}
//オブザーバー・キューが空かどうかを判定する
boolean start = callbacks.isEmpty();
// オブザーバーを追加する
callbacks.add(frameCallback);
// nullではなく、GIFを描画する。
if (start) {
start();
}
}
private void start(){
if(isRunning){
return;
}
isRunning =true;
isCleared=false;
loadNextFrame();
}
void unsubscribe(FrameCallback frameCallback) {
callbacks.remove(frameCallback);
if (callbacks.isEmpty()) {
stop();
}
}
private void loadNextFrame() {
//..
// 現在描画中のフレーム・データはあるか?
if (pendingTarget != null) {
DelayTarget temp = pendingTarget;
pendingTarget = null;
//onFrameReadyを直接呼び出して、現在のフレームが描画されていることをオブザーバーに通知する。.
onFrameReady(temp);
return;
}
isLoadPending = true;
//次のフレームと描画されるフレームとの間の間隔の長さを取得する。
int delay = gifDecoder.getNextDelay();
long targetTime = SystemClock.uptimeMillis() + delay;
// 描画しやすいように、次のフレームを一番上に配置する。.(位置)
gifDecoder.advance();
//遅延メッセージは、DelayTargetのHandlerを使って作成される。.
next = new DelayTarget(handler, gifDecoder.getCurrentFrameIndex(), targetTime);
// GlideFrameSequenceDrawableの読み込み処理 ....with().load().into(); targetTimeで、データ・フレームがフェッチされ、描画される。.
requestBuilder.apply(signatureOf(getFrameSignature())).load(gifDecoder).into(next);
}
@VisibleForTesting
void onFrameReady(DelayTarget delayTarget) {
//....
if (delayTarget.getResource() != null) {
recycleFirstFrame();
DelayTarget previous = current;
current = delayTarget;
// 1. 現在のフレームの描画を実行するオブザーバーへのコールバック。
for (int i = callbacks.size() - 1; i >= 0; i--) {
FrameCallback cb = callbacks.get(i);
cb.onFrameReady();
}
if (previous != null) {
handler.obtainMessage(FrameLoaderCallback.MSG_CLEAR, previous).sendToTarget();
}
}
//2. GIFの次のフレームの読み込みを続ける
loadNextFrame();
}
private class FrameLoaderCallback implements Handler.Callback {
//..
@Override
public boolean handleMessage(Message msg) {
if (msg.what == MSG_DELAY) {
GifFrameLoader.DelayTarget target = (DelayTarget) msg.obj;
onFrameReady(target);
return true;
} else if (msg.what == MSG_CLEAR) {
GifFrameLoader.DelayTarget target = (DelayTarget) msg.obj;
requestManager.clear(target);
}
return false;
}
}
@VisibleForTesting
static class DelayTarget extends SimpleTarget<Bitmap> {
//...
@Override
public void onResourceReady(@NonNull Bitmap resource,
@Nullable Transition<? super Bitmap> transition) {
this.resource = resource;
Message msg = handler.obtainMessage(FrameLoaderCallback.MSG_DELAY, this);
//遅延メッセージは、次のフレームの描画ジョブ・メッセージを送信するハンドラを介して送信される。.
handler.sendMessageAtTime(msg, targetTime);
}
}
}
class GifFrameLoader{
private class FrameLoaderCallback implements Handler.Callback {
static final int MSG_DELAY = 1;
static final int MSG_CLEAR = 2;
@Synthetic
FrameLoaderCallback() { }
@Override
public boolean handleMessage(Message msg) {
if (msg.what == MSG_DELAY) {
// onFrameReadyコールバックは、GifDrawableに次のフレームを描画するよう通知する。
GifFrameLoader.DelayTarget target = (DelayTarget) msg.obj;
onFrameReady(target);
return true;
} else if (msg.what == MSG_CLEAR) {
......
}
return false;
}
}
@VisibleForTesting
void onFrameReady(DelayTarget delayTarget){
//....
if (delayTarget.getResource() != null) {
recycleFirstFrame();
DelayTarget previous = current;
current = delayTarget;
// 1. GIFの現在のフレームを描画するために、オブザーバーコレクションにコールバックする。
for (int i = callbacks.size() - 1; i >= 0; i--) {
FrameCallback cb = callbacks.get(i);
cb.onFrameReady();
}
if (previous != null) {
handler.obtainMessage(FrameLoaderCallback.MSG_CLEAR, previous).sendToTarget();
}
}
// 2. GIFの次のフレームの読み込みを続ける
loadNextFrame();
}
}
上記のメッセージ処理がヒントになります。現在のフレームの描画と次のフレームの読み込みはシリアルで行われるため、これらのステップのいずれかにタイミングのずれがあると、Gifの読み込みの遅れに影響します。
GlideGif の読み込み遅延の最適化
GIFをデコードするためにネイティブレイヤーにGIFLIBを導入することで、メモリ消費量とCPU使用率を大幅に削減し、改善することができます。第二に、GIFアニメーションを描画するためのFrameSequenceDrawableダブルバッファ機構により、JavaレイヤーのBitmapPoolに複数のBitmapを作成する必要がありません。
FrameSequenceDrawableのダブルバッファリング機構を見てみましょう。
public class FrameSequenceDrawable extends Drawable implements Animatable,Runnable{
//....
public FrameSequenceDrawable(FrameSequence frameSequence,BitmapProvider bitmapProvider){
//...
final int width = frameSequence.getWidth();
final int height = frameSequence.getHeight();
//前のフレームのビットマップを描画する
frontBitmap = acquireAndValidateBitmap(bitmapProvider,width,height);
//次のフレームのビットマップを描画する
backBitmap = acquireAndValidateBitmap(bitmapProvider,
width,height);
//.. デコード・スレッドを開始し、バックグラウンドでGif文字のデコードを処理する。
initializeDecodingThread();
}
}
上記の構造から、BitmapProviderを通して2つのBitmapが作成されることが簡単にわかります。
1.GIFアニメ描画スケジューリング
public class FrameSequenceDrawable extends Drawable implements Animatable,Runnable{
@Override
public void start(){
if(!isRunning){
synchronized(mLock){
//..
if(mState == STATE_SCHEDULED){
return;
}
//.デコード操作を実行するには
scheduleDecodeLocked();
}
}
}
private void scheduleDecodeLocked(){
mState = STATE_SCHEDULED;
sDecodingThreadHandler.post(mDecodeRunnable);
}
private final Runnable mDecodeRunnable = new Runnable(){
@Override
public void run(){
//...
try{
//1.次のフレームをデコードする
invalidateTimeMs = mDecoder.getFrame(nextFrame,bitmap,lastFrame);
}catch(Exception e){
//..
}
if (invalidateTimeMs < MIN_DELAY_MS) {
invalidateTimeMs = DEFAULT_DELAY_MS;
}
boolean schedule = false;
Bitmap bitmapToRelease = null;
//
synchronized(mLock){
if(mDestroyed){
bitmapToRelease = mBackBitmap;
mBackBitmap =null;
}else if (mNextFrameToDecode >=0 && mState ==STATE_DECODING){
// 現在のデコード状態と次のフレームでデコードされるデータは0であり、これは次のフレームのデコードが完了したことを意味する。.描画を待つ
schedule = true;
// インターバル描画時間
mNextSwap = exceptionDuringDecode ? Long.MAX_VALUE:invalidateTimeMs+mLastSwap;
mState= STATE_WAITING_TO_SWAP;
}
}
if (schedule) {
// 2. mNextSwapの時点で、描画スケジューリングが行われる。
scheduleSelf(FrameSequenceDrawable.this,mNextSwap);
}
}
@Override
public void run(){
boolean invalidate = false;
synchronized(mLock){
if (mNextFrameToDecode > 0 && mState == STATE_WAITING_TO_SWAP) {
invalidate =true
;
}
}
if (invalidate) {
//3. デコードされたデータを描画する
invalidateSelf();
}
}
}
}
上記のコードから、startメソッドがデコード操作をトリガーしてデコードが完了し、指定された時間にscheduleSelfを呼び出して描画を実行することがわかります。
2. GIF描画とダブルバッファリングの役割
public class FrameSequenceDrawable extends Drawable implements Animatable , Runnable{
@Override
public void draw(@NonNull Canvas canvas){
synchronized(mLock){
checkDestroyLocked();
if (mState == STATE_WAITING_TO_SWAP) {
if (mNextSwap - SystemClock.uptimeMillis()<=0) {
mState = STATE_READY_TO_SWAP;
}
}
if (isRunning() && mState == STATE_READY_TO_SWAP) {
//1.次のフレームのBitmapを前のフレームのBitmapに割り当てる。
Bitmap temp = mBackBitmap;
mBackBitmap = mFrontBitmap;
mFrontBitmap = temp;
//2. 上記のステップが完了すると、デコード・スレッドに次のデコード処理を続行するよう通知される。
if (continueLooping) {
scheduleDecodeLocked();
}else{
scheduleSelf(mFinishedCallbackRunnable,0);
}
}
}
if (mCircleMaskEnabled) {
//...
}else{
//3.現在のフレームを描画する
mPaint.setShader(null);
canvas.drawBitmap(mFrontBitmap,mSrcRect,getBounds(),mPaint);
}
}
}
FrameSequenceDrawableのdrawメソッドでは、mFrontBitmapとmBackBitmapを使って置換を完了させ、すぐに次のフレームをデコードするようにデコードスレッドに通知することで、次のフレームの取得と現在のフレームの描画がほぼ同時に行われるようにしています。
概要
上記の業務プロセスを理解・分析することで、以下のような結論が導き出されます。
1、GIFLIB +ダブルバッファ実装の使用は、2つのビットマップを作成するだけで、メモリ消費量は非常に安定しています。
2、Glideのネイティブロードに比べて、あまりにも大きなGIFイメージをロードする場合、BitmapPoolの利用可能なサイズ以上、または直接Bitmapを作成します。
3、GIFLIBの使用は、直接GIFデータのデコードのネイティブ層では、Glideのこの点は、効率とメモリ消費量がより有利です。
4、Glideは、現在のフレームデータと次のフレームデータをシリアルに構築しますが、FrameSequenceDrawableは、ダブルバッファとデコードサブスレッドを使用して、前のフレームと次のフレームデータのシームレスなほぼ同期完了を実現します。