Android 图片使用导致OOM 性能分析<4>
来源:互联网 发布:地产网络推广公司排名 编辑:程序博客网 时间:2024/06/07 18:55
在开发android app的过程中,常常需要使用图片资源,然而在使用图片资源的过程中,常常因为内存不够导致OOM crash事故.
google 和牛人提供了一下建议,希望能够解决和改善使用图片资源带来的内存使用过度的现象.
使用更小的图片:在设计给到资源图片的时候,我们需要特别留意这张图片是否存在可以压缩的空间,是否可以使用一张更小的图片。尽量使用更小的图片不仅仅可以减少内存的使用,还可以避免出现大量的InflationException。假设有一张很大的图片被XML文件直接引用,很有可能在初始化视图的时候就会因为内存不足而发生InflationException,这个问题的根本原因其实是发生了OOM.
新建一个Android 工程:
<1> :工程树如下:
<2> : 主要代码如下:
DurianMainActivity.java:
package org.durian.durianmemoryenum;import android.content.res.AssetManager;import android.content.res.Resources;import android.graphics.Bitmap;import android.graphics.BitmapFactory;import android.os.Bundle;import android.support.v7.app.ActionBarActivity;import android.util.Log;import android.view.Menu;import android.view.MenuItem;import android.view.View;import android.widget.Button;import android.widget.ImageView;import org.durian.durianmemoryenum.image.DurianImage;public class DurianMainActivity extends ActionBarActivity implements View.OnClickListener{ private final static String TAG="DurianMainActivity"; private Button mButton; private Button mDecodeResStreamButton; private Button mLowButton; private Bitmap mBitmap; private ImageView mImage; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.durian_main); mImage=(ImageView)findViewById(R.id.image); mButton=(Button)findViewById(R.id.button); mButton.setOnClickListener(this); mDecodeResStreamButton=(Button)findViewById(R.id.decoderesstream); mDecodeResStreamButton.setOnClickListener(this); mLowButton=(Button)findViewById(R.id.lowbutton); mLowButton.setOnClickListener(this); } @Override public boolean onCreateOptionsMenu(Menu menu) { // Inflate the menu; this adds items to the action bar if it is present. getMenuInflater().inflate(R.menu.menu_durian_main, menu); return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { // Handle action bar item clicks here. The action bar will // automatically handle clicks on the Home/Up button, so long // as you specify a parent activity in AndroidManifest.xml. int id = item.getItemId(); //noinspection SimplifiableIfStatement if (id == R.id.action_settings) { return true; } return super.onOptionsItemSelected(item); } @Override public void onClick(View v) { int id=v.getId(); switch(id){ case R.id.button: mImage.setImageBitmap(DurianImage.getPerfectImage(DurianMainActivity.this)); break; case R.id.decoderesstream: mImage.setImageBitmap(DurianImage.getDecodeResourceStream(DurianMainActivity.this)); break; case R.id.lowbutton: mImage.setImageBitmap(DurianImage.getLowMemoryImage(DurianMainActivity.this)); break; default: break; } }}
DurianImage.java
package org.durian.durianmemoryenum.image;import android.content.Context;import android.content.res.Resources;import android.graphics.Bitmap;import android.graphics.BitmapFactory;import android.util.Log;import android.util.TypedValue;import org.durian.durianmemoryenum.R;import java.io.InputStream;/** * Project name : DurianMemoryEnum * Created by zhibao.liu on 2016/1/7. * Time : 14:11 * Email warden_sprite@foxmail.com * Action : durian */public class DurianImage { private final static String TAG ="DurianImage"; public static Bitmap getPerfectImage(Context context){ Bitmap mBitmap; /* * 第一步 : 通过设置inJustDecodeBounds=true,首先获取图片相关的信息,这个时候并不会加载图片; * 第二步 : 获取相关信息以后,通过相关信息计算图片的inSampleSize,并且设置. * 第三步 : 通过上面的设置信息,开始获取图片,这个才是真正的要消耗内存的 * */ Resources res= context.getResources(); BitmapFactory.Options options=new BitmapFactory.Options(); //将不返回实际的bitmap不给其分配内存空间而里面只包括一些解码边界信息即图片大小信息 options.inJustDecodeBounds=true; mBitmap=BitmapFactory.decodeResource(res, R.drawable.back,options); //重新读入图片,注意这次要把options.inJustDecodeBounds 设为 false options.inJustDecodeBounds=false; //通过设置inJustDecodeBounds为true,获取到outHeight(图片原始高度)和 outWidth(图片的原始宽度),然后计算一个inSampleSize(缩放值) ////计算缩放比 int be = (int)(options.outHeight / (float)200); if (be <= 0) be = 1; options.inSampleSize=be; /* * inPreferredConfig : 缺省值是ARGB_8888 * ALPHA_8:每个像素占用1byte内存 * ARGB_4444:每个像素占用2byte内存 * ARGB_8888:每个像素占用4byte内存 * RGB_565:每个像素占用2byte内存 * */ //options.inPreferredConfig= Bitmap.Config.ARGB_4444; mBitmap=BitmapFactory.decodeResource(res,R.drawable.back,options); int w=mBitmap.getWidth(); int h=mBitmap.getHeight(); Log.i(TAG,"w : "+w+" h : "+h); return mBitmap; } public static Bitmap getDecodeResourceStream(Context context){ Bitmap map; BitmapFactory.Options options=new BitmapFactory.Options(); TypedValue value=new TypedValue(); InputStream is=context.getResources().openRawResource(R.raw.bks); options.inDensity=100; options.inTargetDensity=120; map=BitmapFactory.decodeResourceStream(context.getResources(),value,is,null,options); return map; } public static Bitmap getLowMemoryImage(Context context){ BitmapFactory.Options options=new BitmapFactory.Options(); options.inPreferredConfig= Bitmap.Config.RGB_565; options.inPurgeable=true; options.inInputShareable=true; InputStream is=context.getResources().openRawResource(R.raw.bks); Bitmap map=BitmapFactory.decodeStream(is,null,options); return map; }}
布局文件:
<?xml version="1.0" encoding="utf-8"?><LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <Button android:id="@+id/button" android:text="button" android:layout_width="wrap_content" android:layout_height="wrap_content" /> <Button android:id="@+id/decoderesstream" android:text="Decode Res Stream" android:layout_width="wrap_content" android:layout_height="wrap_content" /> <Button android:id="@+id/lowbutton" android:text="low image" android:layout_width="wrap_content" android:layout_height="wrap_content" /> <ImageView android:id="@+id/image" android:scaleType="center" android:src="@drawable/bks" android:layout_width="wrap_content" android:layout_height="wrap_content" /></LinearLayout>
其中back.png是一张超级大的图片2500*1960的,大概15M
bk.png是一样宽1000多的,
bks是一张宽300多的.
<3> : 运行看结果:通过点击按钮不断切换,会发现只有点击第一个button的时候,非常的慢:
没有使用GPU加速的情况下:点击按钮的时候会有一个小高峰.
内存开支: 不设置格式的情况下:
设置格式以后:
options.inPreferredConfig= Bitmap.Config.RGB_565;其他的格式可以自行比较.
再点击第二个按钮:
内存开支:
点击第三个按钮时:有点点看不到了,大概在5m 和40s之间的位置
内存开支:
<4> : 程序重点,请参考里面的步骤
public static Bitmap getPerfectImage(Context context){ Bitmap mBitmap; /* * 第一步 : 通过设置inJustDecodeBounds=true,首先获取图片相关的信息,这个时候并不会加载图片; * 第二步 : 获取相关信息以后,通过相关信息计算图片的inSampleSize,并且设置. * 第三步 : 通过上面的设置信息,开始获取图片,这个才是真正的要消耗内存的 * */ Resources res= context.getResources(); BitmapFactory.Options options=new BitmapFactory.Options(); //将不返回实际的bitmap不给其分配内存空间而里面只包括一些解码边界信息即图片大小信息 options.inJustDecodeBounds=true; mBitmap=BitmapFactory.decodeResource(res, R.drawable.back,options); //重新读入图片,注意这次要把options.inJustDecodeBounds 设为 false options.inJustDecodeBounds=false; //通过设置inJustDecodeBounds为true,获取到outHeight(图片原始高度)和 outWidth(图片的原始宽度),然后计算一个inSampleSize(缩放值) ////计算缩放比 int be = (int)(options.outHeight / (float)200); if (be <= 0) be = 1; options.inSampleSize=be; /* * inPreferredConfig : 缺省值是ARGB_8888 * ALPHA_8:每个像素占用1byte内存 * ARGB_4444:每个像素占用2byte内存 * ARGB_8888:每个像素占用4byte内存 * RGB_565:每个像素占用2byte内存 * */ //options.inPreferredConfig= Bitmap.Config.ARGB_4444; mBitmap=BitmapFactory.decodeResource(res,R.drawable.back,options); int w=mBitmap.getWidth(); int h=mBitmap.getHeight(); Log.i(TAG,"w : "+w+" h : "+h); return mBitmap; }
这里要说明一个点:
/* * inPreferredConfig : 缺省值是ARGB_8888 * ALPHA_8:每个像素占用1byte内存 * ARGB_4444:每个像素占用2byte内存 * ARGB_8888:每个像素占用4byte内存 * RGB_565:每个像素占用2byte内存 * */ //options.inPreferredConfig= Bitmap.Config.ARGB_4444;
按照专家给出的建议,除了对图标进行缩放减少内存消耗,还可以设置对图片的加载格式进行设置.
<5> : 对于上面的加载图片中的参数配置,发现在没有配置的时候,那么系统是如何配置默认的呢,下面可以看一下系统源代码
BitmapFactory.decodeResource:
/** * Synonym for opening the given resource and calling * {@link #decodeResourceStream}. * * @param res The resources object containing the image data * @param id The resource id of the image data * @param opts null-ok; Options that control downsampling and whether the * image should be completely decoded, or just is size returned. * @return The decoded bitmap, or null if the image data could not be * decoded, or, if opts is non-null, if opts requested only the * size be returned (in opts.outWidth and opts.outHeight) */ public static Bitmap decodeResource(Resources res, int id, Options opts) { Bitmap bm = null; InputStream is = null; try { final TypedValue value = new TypedValue(); is = res.openRawResource(id, value); bm = decodeResourceStream(res, value, is, null, opts); } catch (Exception e) { /* do nothing. If the exception happened on open, bm will be null. If it happened on close, bm is still valid. */ } finally { try { if (is != null) is.close(); } catch (IOException e) { // Ignore } } if (bm == null && opts != null && opts.inBitmap != null) { throw new IllegalArgumentException("Problem decoding into existing bitmap"); } return bm; }
我们发现上面只是开发者提供基本的参数进去,去"open"资源都在里面自动实现了,然后进一步调用了decodeResourceStream方法:
/** * Decode a new Bitmap from an InputStream. This InputStream was obtained from * resources, which we pass to be able to scale the bitmap accordingly. */ public static Bitmap decodeResourceStream(Resources res, TypedValue value, InputStream is, Rect pad, Options opts) { if (opts == null) { opts = new Options(); } if (opts.inDensity == 0 && value != null) { final int density = value.density; if (density == TypedValue.DENSITY_DEFAULT) { opts.inDensity = DisplayMetrics.DENSITY_DEFAULT; } else if (density != TypedValue.DENSITY_NONE) { opts.inDensity = density; } } if (opts.inTargetDensity == 0 && res != null) { opts.inTargetDensity = res.getDisplayMetrics().densityDpi; } return decodeStream(is, pad, opts); }
在这里面检查了密度,如果开发者自行设置了密度,那么就是用开发者的密度参数,如果没有,这里提供了一个默认的DisplayMetrics.DENSITY_DEFAULT的,密度是针对LCD屏幕的,这个参数设置的越小,图片会越不清晰,但是越高耗内存,甚至无法显示.系统会自行根据设备当前的屏幕密度配置,这个地方可以解释平时android工程里面图片资源分类的放法:
hdpi,mdpi,xhdpi...等等,设备会根据密度去到不同的资源文件夹加载不同的资源.这就是平时说的适配.
继续前面的:
public static Bitmap decodeStream(InputStream is, Rect outPadding, Options opts) { // we don't throw in this case, thus allowing the caller to only check // the cache, and not force the image to be decoded. if (is == null) { return null; } // we need mark/reset to work properly if (!is.markSupported()) { is = new BufferedInputStream(is, DECODE_BUFFER_SIZE); } // so we can call reset() if a given codec gives up after reading up to // this many bytes. FIXME: need to find out from the codecs what this // value should be. is.mark(1024); Bitmap bm; boolean finish = true; if (is instanceof AssetManager.AssetInputStream) { final int asset = ((AssetManager.AssetInputStream) is).getAssetInt(); if (opts == null || (opts.inScaled && opts.inBitmap == null)) { float scale = 1.0f; int targetDensity = 0; if (opts != null) { final int density = opts.inDensity; targetDensity = opts.inTargetDensity; if (density != 0 && targetDensity != 0) { scale = targetDensity / (float) density; } } bm = nativeDecodeAsset(asset, outPadding, opts, true, scale); if (bm != null && targetDensity != 0) bm.setDensity(targetDensity); finish = false; } else { bm = nativeDecodeAsset(asset, outPadding, opts); } } else { // pass some temp storage down to the native code. 1024 is made up, // but should be large enough to avoid too many small calls back // into is.read(...) This number is not related to the value passed // to mark(...) above. byte [] tempStorage = null; if (opts != null) tempStorage = opts.inTempStorage; if (tempStorage == null) tempStorage = new byte[16 * 1024]; if (opts == null || (opts.inScaled && opts.inBitmap == null)) { float scale = 1.0f; int targetDensity = 0; if (opts != null) { final int density = opts.inDensity; targetDensity = opts.inTargetDensity; if (density != 0 && targetDensity != 0) { scale = targetDensity / (float) density; } } bm = nativeDecodeStream(is, tempStorage, outPadding, opts, true, scale); if (bm != null && targetDensity != 0) bm.setDensity(targetDensity); finish = false; } else { bm = nativeDecodeStream(is, tempStorage, outPadding, opts); } } if (bm == null && opts != null && opts.inBitmap != null) { throw new IllegalArgumentException("Problem decoding into existing bitmap"); } return finish ? finishDecode(bm, outPadding, opts) : bm; }
一直跟踪:
static jobject nativeDecodeAsset(JNIEnv* env, jobject clazz, jint native_asset, jobject padding, jobject options) { return nativeDecodeAssetScaled(env, clazz, native_asset, padding, options, false, 1.0f);}
static jobject nativeDecodeAssetScaled(JNIEnv* env, jobject clazz, jint native_asset, jobject padding, jobject options, jboolean applyScale, jfloat scale) { SkStream* stream; Asset* asset = reinterpret_cast<Asset*>(native_asset); bool forcePurgeable = optionsPurgeable(env, options); if (forcePurgeable) { // if we could "ref/reopen" the asset, we may not need to copy it here // and we could assume optionsShareable, since assets are always RO stream = copyAssetToStream(asset); if (stream == NULL) { return NULL; } } else { // since we know we'll be done with the asset when we return, we can // just use a simple wrapper stream = new AssetStreamAdaptor(asset); } SkAutoUnref aur(stream); return doDecode(env, stream, padding, options, true, forcePurgeable, applyScale, scale);}
// since we "may" create a purgeable imageref, we require the stream be ref'able// i.e. dynamically allocated, since its lifetime may exceed the current stack// frame.static jobject doDecode(JNIEnv* env, SkStream* stream, jobject padding, jobject options, bool allowPurgeable, bool forcePurgeable = false, bool applyScale = false, float scale = 1.0f) { int sampleSize = 1; SkImageDecoder::Mode mode = SkImageDecoder::kDecodePixels_Mode; SkBitmap::Config prefConfig = SkBitmap::kARGB_8888_Config; bool doDither = true; bool isMutable = false; bool willScale = applyScale && scale != 1.0f; bool isPurgeable = !willScale && (forcePurgeable || (allowPurgeable && optionsPurgeable(env, options))); bool preferQualityOverSpeed = false; jobject javaBitmap = NULL; if (options != NULL) { sampleSize = env->GetIntField(options, gOptions_sampleSizeFieldID); if (optionsJustBounds(env, options)) { mode = SkImageDecoder::kDecodeBounds_Mode; } // initialize these, in case we fail later on env->SetIntField(options, gOptions_widthFieldID, -1); env->SetIntField(options, gOptions_heightFieldID, -1); env->SetObjectField(options, gOptions_mimeFieldID, 0); jobject jconfig = env->GetObjectField(options, gOptions_configFieldID); prefConfig = GraphicsJNI::getNativeBitmapConfig(env, jconfig); isMutable = env->GetBooleanField(options, gOptions_mutableFieldID); doDither = env->GetBooleanField(options, gOptions_ditherFieldID); preferQualityOverSpeed = env->GetBooleanField(options, gOptions_preferQualityOverSpeedFieldID); javaBitmap = env->GetObjectField(options, gOptions_bitmapFieldID); } if (willScale && javaBitmap != NULL) { return nullObjectReturn("Cannot pre-scale a reused bitmap"); } SkImageDecoder* decoder = SkImageDecoder::Factory(stream); if (decoder == NULL) { return nullObjectReturn("SkImageDecoder::Factory returned null"); } decoder->setSampleSize(sampleSize); decoder->setDitherImage(doDither); decoder->setPreferQualityOverSpeed(preferQualityOverSpeed); NinePatchPeeker peeker(decoder); JavaPixelAllocator javaAllocator(env); SkBitmap* bitmap; if (javaBitmap == NULL) { bitmap = new SkBitmap; } else { if (sampleSize != 1) { return nullObjectReturn("SkImageDecoder: Cannot reuse bitmap with sampleSize != 1"); } bitmap = (SkBitmap*) env->GetIntField(javaBitmap, gBitmap_nativeBitmapFieldID); // config of supplied bitmap overrules config set in options prefConfig = bitmap->getConfig(); } SkAutoTDelete<SkImageDecoder> add(decoder); SkAutoTDelete<SkBitmap> adb(bitmap, javaBitmap == NULL); decoder->setPeeker(&peeker); if (!isPurgeable) { decoder->setAllocator(&javaAllocator); } AutoDecoderCancel adc(options, decoder); // To fix the race condition in case "requestCancelDecode" // happens earlier than AutoDecoderCancel object is added // to the gAutoDecoderCancelMutex linked list. if (options != NULL && env->GetBooleanField(options, gOptions_mCancelID)) { return nullObjectReturn("gOptions_mCancelID"); } SkImageDecoder::Mode decodeMode = mode; if (isPurgeable) { decodeMode = SkImageDecoder::kDecodeBounds_Mode; } SkBitmap* decoded; if (willScale) { decoded = new SkBitmap; } else { decoded = bitmap; } SkAutoTDelete<SkBitmap> adb2(willScale ? decoded : NULL); if (!decoder->decode(stream, decoded, prefConfig, decodeMode, javaBitmap != NULL)) { return nullObjectReturn("decoder->decode returned false"); } int scaledWidth = decoded->width(); int scaledHeight = decoded->height(); if (willScale && mode != SkImageDecoder::kDecodeBounds_Mode) { scaledWidth = int(scaledWidth * scale + 0.5f); scaledHeight = int(scaledHeight * scale + 0.5f); } // update options (if any) if (options != NULL) { env->SetIntField(options, gOptions_widthFieldID, scaledWidth); env->SetIntField(options, gOptions_heightFieldID, scaledHeight); env->SetObjectField(options, gOptions_mimeFieldID, getMimeTypeString(env, decoder->getFormat())); } // if we're in justBounds mode, return now (skip the java bitmap) if (mode == SkImageDecoder::kDecodeBounds_Mode) { return NULL; } jbyteArray ninePatchChunk = NULL; if (peeker.fPatch != NULL) { if (willScale) { scaleNinePatchChunk(peeker.fPatch, scale); } size_t ninePatchArraySize = peeker.fPatch->serializedSize(); ninePatchChunk = env->NewByteArray(ninePatchArraySize); if (ninePatchChunk == NULL) { return nullObjectReturn("ninePatchChunk == null"); } jbyte* array = (jbyte*) env->GetPrimitiveArrayCritical(ninePatchChunk, NULL); if (array == NULL) { return nullObjectReturn("primitive array == null"); } peeker.fPatch->serialize(array); env->ReleasePrimitiveArrayCritical(ninePatchChunk, array, 0); } jintArray layoutBounds = NULL; if (peeker.fLayoutBounds != NULL) { layoutBounds = env->NewIntArray(4); if (layoutBounds == NULL) { return nullObjectReturn("layoutBounds == null"); } jint scaledBounds[4]; if (willScale) { for (int i=0; i<4; i++) { scaledBounds[i] = (jint)((((jint*)peeker.fLayoutBounds)[i]*scale) + .5f); } } else { memcpy(scaledBounds, (jint*)peeker.fLayoutBounds, sizeof(scaledBounds)); } env->SetIntArrayRegion(layoutBounds, 0, 4, scaledBounds); if (javaBitmap != NULL) { env->SetObjectField(javaBitmap, gBitmap_layoutBoundsFieldID, layoutBounds); } } if (willScale) { // This is weird so let me explain: we could use the scale parameter // directly, but for historical reasons this is how the corresponding // Dalvik code has always behaved. We simply recreate the behavior here. // The result is slightly different from simply using scale because of // the 0.5f rounding bias applied when computing the target image size const float sx = scaledWidth / float(decoded->width()); const float sy = scaledHeight / float(decoded->height()); SkBitmap::Config config = decoded->config(); switch (config) { case SkBitmap::kNo_Config: case SkBitmap::kIndex8_Config: case SkBitmap::kRLE_Index8_Config: config = SkBitmap::kARGB_8888_Config; break; default: break; } bitmap->setConfig(config, scaledWidth, scaledHeight); bitmap->setIsOpaque(decoded->isOpaque()); if (!bitmap->allocPixels(&javaAllocator, NULL)) { return nullObjectReturn("allocation failed for scaled bitmap"); } bitmap->eraseColor(0); SkPaint paint; paint.setFilterBitmap(true); SkCanvas canvas(*bitmap); canvas.scale(sx, sy); canvas.drawBitmap(*decoded, 0.0f, 0.0f, &paint); } if (padding) { if (peeker.fPatch != NULL) { GraphicsJNI::set_jrect(env, padding, peeker.fPatch->paddingLeft, peeker.fPatch->paddingTop, peeker.fPatch->paddingRight, peeker.fPatch->paddingBottom); } else { GraphicsJNI::set_jrect(env, padding, -1, -1, -1, -1); } } SkPixelRef* pr; if (isPurgeable) { pr = installPixelRef(bitmap, stream, sampleSize, doDither); } else { // if we get here, we're in kDecodePixels_Mode and will therefore // already have a pixelref installed. pr = bitmap->pixelRef(); } if (pr == NULL) { return nullObjectReturn("Got null SkPixelRef"); } if (!isMutable) { // promise we will never change our pixels (great for sharing and pictures) pr->setImmutable(); } // detach bitmap from its autodeleter, since we want to own it now adb.detach(); if (javaBitmap != NULL) { // If a java bitmap was passed in for reuse, pass it back return javaBitmap; } // now create the java bitmap return GraphicsJNI::createBitmap(env, bitmap, javaAllocator.getStorageObj(), isMutable, ninePatchChunk, layoutBounds, -1);}
一直跟踪到这里,就可以看出,基本上就可以看出整个图片如何解码的.有兴趣的可以仔细看一看.
0 0
- Android 图片使用导致OOM 性能分析<4>
- android图片加载导致的OOM分析及有效解决办法(BitmapUtils)
- 图片过大导致OOM
- Android读取本地图片,图片太大导致OOM问题。
- 解决android缩放图片导致OOM的一个方案
- android图片选择由于版本导致的oom解决办法
- android 手机拍照后获取图片导致OOM问题
- 【Android问题及其解决】又见图片导致的OOM
- Elasticsearch使用TTL导致OOM问题分析解决
- Android使用bitmap导致内存溢出(oom)问题
- Android开发笔记图片缓存 手势及OOM分析
- 页面加载大量图片导致OOM
- Android OOM分析总结
- android oom分析
- android oom 分析
- android OOM异常分析
- android oom分析步骤
- Android OOM分析
- [软件人生]关于同行竞业,你需要知道,你需要关注后续
- 欢迎使用CSDN-markdown编辑器
- 设计模式-单例模式
- Yii 1.0数据库操作 查询、增加、更新、删除(事务处理)
- Java集合 Map的遍历
- Android 图片使用导致OOM 性能分析<4>
- 大端小端格式 理解
- spark rdd checkpoint的用法注意点
- UML详解之二——类图
- 【南理oj】218 - Dinner (水)
- 设计模式-原型模式
- 分布式搜索elasticsearch配置文件详解
- 超过100W份源码免费下载
- Ubuntu 下编译 openJDK