Android实践:自实现Memory/DiskCache逻辑

本文深入探讨了Android中自定义Memory/DiskCache的实现过程,详细解析了journal文件的作用,以及LRU缓存策略在内存和磁盘缓存中的应用。通过理解Snapshot和Editor机制,提升应用的性能和用户体验。
在Android开发过程中,我们接触的缓存技术主要分为内存缓存和磁盘缓存。它们一般会被用来缓存http返回result、下载后渲染的Bitmap和文案等等。虽然现在已经有了比较成熟的缓存框架和工具,为了能让大家更加了解底层的实现和使用,下面我们就分别从原理和实践方面,给大家介绍下这两种技术:

一、内存缓存
1.简介

  会占用内存成本,但是达到快速访问目的;  
  合适缓存大小没有准则,考虑屏幕密度和大小,图片数量和质量,访问频率等因素来权衡;
  缓存过小导致额外的开销,过大导致OOM;
  注意:过去常用SoftReference或者WeakReference来实现Bitmap的缓存,现在并不推荐。因为从Android2.3的垃圾回收器更积极的回收它们,使得它们基本无效;Android3.0之前,开发不需要对Btimap进行手动的释放,内存中保存的位图数据回收不可预测,可能导致程序超出内存限制崩溃;
2.原理
关于内存缓存的实现,核心是LinkedHashMap<K, V>数据结构。它以强引用的方式保存了对象写入顺序,当我们的操作缓存(写和读都是线程安全的)到达极限就删除最近最少被使用的缓存,直到达到目标大小。从而实现Lru(Least Recently Used)算法;
3.使用
使用LruCache,有如下步骤:
  初始化LruCache,指定缓存的大小;
  重写sizeOf()方法,实现对缓存的数据大小计算逻辑;
  根据需要,是否要重写entryRemoved()方法;
  根据业务,使用key调用get()和put()方法读写缓存;

二、硬盘缓存
1.简介:

  内存缓存解决了快速访问的问题,但是当加载大量图片会很容易达到内存缓存限制;
  应用程序被其它任务(如接入电话)打断进入后台,Activity被杀死后内存缓存丢失,恢复后还得重新获取;
  当位图在内存缓存中不再可用时,硬盘缓存来保存这些位图,来减少网络下载次数;
  当然硬盘读取位图的速度比内存慢,所以应该在后台线程中进行;
2.原理:
关于硬盘缓存的实现原理,我们要关系的核心有如下2方面的问题:
  硬盘缓存相关信息是以什么形式保存的?
  硬盘缓存是如何读写,并实现Lru算法的?
问题1:硬盘缓存相关信息是通过两种文件进行记录的;
  缓存日志文件:该文件记录了磁盘缓存当前状态,执行的所有写入、保存、删除、读取动作;
    libcore.io.DiskLruCache
    1
    100
    2
    CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054
    DIRTY 335c4c6028171cfddfbaae1a9c313c52
    CLEAN 335c4c6028171cfddfbaae1a9c313c52 3934 2342
    REMOVE 335c4c6028171cfddfbaae1a9c313c52
    DIRTY 1ab96a171faeeee38496d8b330771a7a
    CLEAN 1ab96a171faeeee38496d8b330771a7a 1600 234
    READ 335c4c6028171cfddfbaae1a9c313c52
    READ 3400330d1dfc7f3f7f4b8d4d803dfcf6
    第一部分:
      CLEAN:缓存写入完毕,成为正式的缓存;
      DIRTY:正在写入的缓存,成功后就会继续记录一条CLEAN日志
      REMOVE:删除缓存;
      READ:读取缓存;
    第二部分:缓存对应的key;
    第三部分:
      缓存文件的长度。一条缓存可能存在多个缓存文件,如果有多个缓存文件,则有多个文件的长度;
      DIRTY时,写入时缓存文件名称为"key."+i+".tmp"。CLEAN时,写入文件更名为"key."+i(i为第几个缓存文件);
    解释:CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054
      CLEAN:成功写入一条缓存;
      3400330d1dfc7f3f7f4b8d4d803dfcf6 :key为3400330d1dfc7f3f7f4b8d4d803dfcf6;
      832 21054:该条缓存有两个缓存文件,分别为3400330d1dfc7f3f7f4b8d4d803dfcf6.0.tmp和3400330d1dfc7f3f7f4b8d4d803dfcf6.1.tmp,长度分别为832和21054;
  缓存文件:该文件保存了我们具体缓存的内容。如图片,字符串等;
问题2:
  硬盘缓存初始化时,逐条读取缓存日志文件中每一条操作日志,“还原”缓存对象集合;
  每个缓存对象包含:key—唯一标识每天缓存;名为key.index的缓存文件流集合—用于读写缓存文件;
  每次执行完CLEAN,DIRTY,REMOVE等动作后,将检查缓存对象集合缓存的个数是否超过限制,如果超过则删除最早保存的缓存;
3.使用
使用DiskLruCache的步骤如下:
  异步任务中,调用open()方法初始化硬盘缓存,并指定缓存的目录;
  根据业务,使用key调用get(key)方法返回Snapshot对象,通过该对象调用getInpuStream(index)获取对应缓存文件的输入流,来读取缓存数据;
  根据业务,使用key调用edit(key)方法返回Editor对象,通过该对象调用newOutPutStream(index)获取要缓存文件的输出流,来写入缓存数据,别忘记commit()提交;

三、实践
相信大家对两种缓存技术已经有了一定的认识,下面我们就结合一个简单的图片列表的实例,跟大家展示一下如何使用内存缓存和硬盘缓存技术。
  首次从服务端下载图片,分别存储在内存缓存和磁盘缓存中,然后展示在列表中;
  后面先从内存缓存中查找图片,如有没有则从磁盘缓存查找,再没有从服务端下载(完成后存入内存和磁盘缓存),然后展示在列表中;
1.项目目录

ImageListAdapter.java:图片列表适配器,处理了列表的展示和缓存相关逻辑(这里仅仅用于演示,故并没有缓存等相关功能单独抽出来);
DiskLruCache.java:从Android演示项目DisplayingBitmaps复制该硬盘缓存实现文件;
CacheList.java:图片列表显示Activity;
2.代码实现
CacheActivity.java
public class CacheActivity extends AppCompatActivity{
    private GridView gridView;
    private ImageListAdapter imageListAdapter;


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_cache);


        gridView = (GridView) findViewById(R.id.gridview1);
        ArrayList<String> imageUrlList = new ArrayList<>();
        Random random = new Random();
        for (int i = 0; i < 40; i++) {
            imageUrlList.add("http://localhost:8080/qserver/img/" + random.nextInt(16) % 6 + ".jpg");


        }


        //显示图片列表
        imageListAdapter = new ImageListAdapter(this, imageUrlList);
        gridView.setAdapter(imageListAdapter);
    }
}
activity_cache.xml
<?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:id="@+id/activity_cache"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    ... ... 
    tools:context="com.qunar.hotel.CacheActivity">


    <GridView
        android:id="@+id/gridview1"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:numColumns="3" />
</LinearLayout>
ImageListAdapter.java
public class ImageListAdapter extends BaseAdapter {
    private static final String TAG = "ImageListAdapter";
    private final Context context;
    //显示图片url集合
    private List<String> imageUrlList;

    //内存缓存
    private LruCache<String, Bitmap> memoryCache;

    //硬盘缓存
    private DiskLruCache diskLruCache;
    //硬盘缓存锁,应为硬盘缓存涉及到文件的操作,用来控制线程安全
    private final Object diskCacheLock = new Object();
    //硬盘缓存是否正在初始化,未初始化完成,其它缓存Task必须等待
    private boolean diskCacheStarting = true;
    //硬盘缓存大小
    private static final int DISK_CACHE_SIZE = 1024 * 1024 * 10;
    //硬盘缓存目录名称
    private static final String DISK_CACHE_SUBDIR = "thumbnails";

    public ImageListAdapter(Context context, ArrayList<String> imageUrlList) {
        this.context = context;
        this.imageUrlList = imageUrlList;

        //初始化内存缓存,指定内存缓存大小,并实现sizeof方法计算每个缓存实体的大小
        final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
        final int cacheSize = maxMemory / 8;
        memoryCache = new LruCache<String, Bitmap>(cacheSize) {
            @Override
            protected int sizeOf(String key, Bitmap bitmap) {
                return bitmap.getByteCount() / 1024;
            }
        };

        //异步任务初始化硬盘缓存
        File cacheDir = getDiskCacheDir(context, DISK_CACHE_SUBDIR);
        new InitDiskCacheTask().execute(cacheDir);
    }

    /**
     * 获取硬盘缓存目录,优先使用SD卡或者内置外存
     */
    public static File getDiskCacheDir(Context context, String uniqueName) {
        final String cachePath =
                Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) ||
                        !isExternalStorageRemovable() ? context.getExternalCacheDir().getPath() :
                        context.getCacheDir().getPath();
        return new File(cachePath + File.separator + uniqueName);
    }

    /**
     * 初始化硬盘缓存异步任务
     */
    class InitDiskCacheTask extends AsyncTask<File, Void, Void> {
        @Override
        protected Void doInBackground(File... params) {
            synchronized (diskCacheLock) {
                try {
                    //调用open()方法,指定缓存目录,初始化硬盘缓存。完毕后释放锁让其它线程执行
                    File cacheDir = params[0];
                    diskLruCache = DiskLruCache.open(cacheDir, 1, 1, DISK_CACHE_SIZE);
                    diskCacheStarting = false;
                    diskCacheLock.notifyAll();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            return null;
        }
    }

    @Override
    public int getCount() {
        return imageUrlList.size();
    }

    @Override
    public Object getItem(int position) {
        return imageUrlList.get(position);
    }

    @Override
    public long getItemId(int position) {
        return position;
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        View viewItem;
        ViewHolder viewHolder;

        if (convertView == null) {
            viewItem = View.inflate(context, R.layout.gridview_item, null);
            viewHolder = new ViewHolder();
            viewHolder.imageView = (ImageView) viewItem.findViewById(R.id.imageview1);
            viewItem.setTag(viewHolder);
        } else {
            viewItem = convertView;
            viewHolder = (ViewHolder) viewItem.getTag();
        }


        String url = imageUrlList.get(position);
        ImageView imageView = viewHolder.imageView;


        //先从内存缓存中获取,没有执行异步任务从硬盘缓存或者网络获取
        final Bitmap bitmap = getBitmapFromMemCache(url);
        if (bitmap != null) {
            imageView.setImageBitmap(bitmap);
            Log.i(TAG,"get bitmap " + url + "from memeory cache.");
        } else {
            //检查复用的imageView当前是否相关的异步任务正在获取图片,如果获取的不是同一张则取消
            if (cancelPotentialAsyncTask(url, imageView)) {
                //创建并执行异步任务,并将任务关联到默认图的Drawble,设置给imageView
                final DownLoadBitmapTask downLoadBitmapTask = new DownLoadBitmapTask(imageView);
                final AsyncDrawable asyncDrawable = new AsyncDrawable(context.getResources(), BitmapFactory.
                        decodeResource(context.getResources(), R.drawable.default_img), downLoadBitmapTask);
                imageView.setImageDrawable(asyncDrawable);
                downLoadBitmapTask.execute(url);
            }
        }
        return viewItem;
    }

    static class ViewHolder {
        ImageView imageView;
    }

    /**
     * 检查复用的imageView当前是否相关的异步任务正在获取图片,如果获取的不是同一张则取消,创建新的task获取你想要的图片;如果是同一张,则继续执行任务获取
     *
     * @param url       当前获取图片url
     * @param imageView 当前使用imageView
     * @return 是否取消了重复任务
     */
    public static boolean cancelPotentialAsyncTask(String url, ImageView imageView) {
        final DownLoadBitmapTask bitmapWorkerTask = getBitmapTaskByImageView(imageView);
        if (bitmapWorkerTask != null) {
            final String bitmapUrl = bitmapWorkerTask.url;
            //不是同一张图片,则取消原来的任务,创建新的任务获取
            if (bitmapUrl == null || bitmapUrl != url) {
                bitmapWorkerTask.cancel(true);
            }
            //如果加载的是同一个图片,则继续异步获取,不创建新的任务下载
            else {
                return false;
            }
        }
        return true;
    }

    /**
     * 获取imageview当前对应的异步下载任务
     *
     * @param imageView image对象
     * @return 关联的异步任务
     */
    private static DownLoadBitmapTask getBitmapTaskByImageView(ImageView imageView) {
        if (imageView != null) {
            final Drawable drawable = imageView.getDrawable();
            if (drawable instanceof AsyncDrawable) {
                final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable;
                return asyncDrawable.getBitmapWorkerTask();
            }
        }
        return null;
    }

    /**
     * 异步Drawable,关联下载该图片的异步任务
     */
    static class AsyncDrawable extends BitmapDrawable {
        //下载位图对应的异步任务
        private final WeakReference<DownLoadBitmapTask> downLoadBitmapTaskWeakReference;

        public AsyncDrawable(Resources res, Bitmap bitmap, DownLoadBitmapTask downLoadBitmapTask) {
            super(res, bitmap);
            downLoadBitmapTaskWeakReference = new WeakReference<>(downLoadBitmapTask);
        }

        public DownLoadBitmapTask getBitmapWorkerTask() {
            return downLoadBitmapTaskWeakReference.get();
        }
    }

    /**
     * 下载位图异步任务
     */
    private class DownLoadBitmapTask extends AsyncTask<String, Void, Bitmap> {
        private final WeakReference<ImageView> imageViewWeakReference;
        private Bitmap bitmap;
        private String url;

        private DownLoadBitmapTask(ImageView imageView) {
            this.imageViewWeakReference = new WeakReference<>(imageView);
        }

        @Override
        protected Bitmap doInBackground(String... params) {
            try {
                //先从硬盘缓存获取,否则从服务器获取,成功后添加到内存和硬盘缓存
                url = params[0];
                bitmap = getBitmapFromDiskCache(url);
                if (bitmap == null) {
                    bitmap = downloadBitmapFromUrl(url);
                     Log.i(TAG,"get bitmap " + url + "from server.");
                } else {
                     Log.i(TAG,"get bitmap " + url + "from server.");
                }
                addBitmapToCache(url, bitmap);
            } catch (IOException e) {
                e.printStackTrace();
            }
            return bitmap;
        }

        @Override
        protected void onPostExecute(Bitmap bitmap) {
            if (isCancelled()) {
                bitmap = null;
            }

            //如果activity退出,或者配合发生改变重建时,imageView不一定存在,故需要检测
            if (imageViewWeakReference != null && bitmap != null) {
                final ImageView imageView = imageViewWeakReference.get();
                final DownLoadBitmapTask downLoadBitmapTask = getBitmapTaskByImageView(imageView);
                if (this == downLoadBitmapTask && imageView != null) {
                    imageView.setImageBitmap(bitmap);
                }
            }
        }
    }

    /**
     * 向内存缓存和硬盘缓存缓存位图
     *
     * @param url    url
     * @param bitmap 位图
     */
    public void addBitmapToCache(String url, Bitmap bitmap) {
        //保存到内存缓存
        if (getBitmapFromMemCache(url) == null) {
            memoryCache.put(url, bitmap);
            Log.i(TAG,"add bitmap " + url + "to memory cache.");
        }

        //保存到硬盘缓存
        synchronized (diskCacheLock) {
            if (diskLruCache != null) {
                final String key = hashKeyForDisk(url);
                OutputStream outputStream = null;
                try {
                    DiskLruCache.Snapshot snapshot = diskLruCache.get(key);
                    //不存在则获取Editor获取输出流,写入缓存
                    if (snapshot == null) {
                        DiskLruCache.Editor editor = diskLruCache.edit(key);
                        if (editor != null) {
                            outputStream = editor.newOutputStream(0);
                            bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream);
                            editor.commit();
                            Log.i(TAG,"add bitmap " + url + "to cache cache.");
                        }
                    } else {
                        snapshot.getInputStream(0).close();
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                } finally {
                    try {
                        if (outputStream != null) {
                            outputStream.close();
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }

    /**
     * 从内存缓存中获取指定url的图片
     *
     * @param url url
     * @return 位图
     */
    public Bitmap getBitmapFromMemCache(String url) {
        return memoryCache.get(url);
    }

    /**
     * 从磁盘缓存中获取url的位图
     *
     * @param url url
     * @return 位图
     */
    public Bitmap getBitmapFromDiskCache(String url) {
        synchronized (diskCacheLock) {
            //如果磁盘缓存正在初始化,则等待初始化完成
            while (diskCacheStarting) {
                try {
                    diskCacheLock.wait();
                } catch (InterruptedException e) {
                }
            }

            Bitmap bitmap = null;
            if (diskLruCache != null) {
                InputStream inputStream = null;
                //将图片的url生成md5哈希值作为硬盘缓存的key
                final String key = hashKeyForDisk(url);
                try {
                    //通过Snapshot获取输出流,读取key对应的缓存文件
                    DiskLruCache.Snapshot snapshot = diskLruCache.get(key);
                    if (snapshot != null) {
                        inputStream = snapshot.getInputStream(0);
                        if (inputStream != null) {
                            FileDescriptor fileDescriptor = ((FileInputStream) inputStream).getFD();
                            bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor);
                        }
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                } finally {
                    try {
                        if (inputStream != null) {
                            inputStream.close();
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
            return bitmap;
        }
    }

    /**
     * 从服务端下载位图
     *
     * @param url url
     * @return 位图
     * @throws IOException
     */
    private Bitmap downloadBitmapFromUrl(String url) throws IOException {
        Bitmap bitmap = null;
        InputStream is = null;

        try {
            URL url1 = new URL(url);
            HttpURLConnection conn = (HttpURLConnection) url1.openConnection();

            conn.setReadTimeout(1000);
            conn.setConnectTimeout(15000);
            conn.setRequestMethod("GET");
            conn.setDoInput(true);
            conn.connect();

            int response = conn.getResponseCode();
            if (response == 200) {
                is = conn.getInputStream();
                bitmap = BitmapFactory.decodeStream(is);
            }
        } catch (ProtocolException e) {
            e.printStackTrace();
        } catch (MalformedURLException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (is != null) {
                is.close();
            }
        }
        return bitmap;
    }

    /**
     * 生成图片url对应的mds值作为key
     */
    public static String hashKeyForDisk(String url) {
        String cacheKey;
        try {
            final MessageDigest mDigest = MessageDigest.getInstance("MD5");
            mDigest.update(url.getBytes());
            cacheKey = bytesToHexString(mDigest.digest());
        } catch (NoSuchAlgorithmException e) {
            cacheKey = String.valueOf(url.hashCode());
        }
        return cacheKey;
    }

    private static String bytesToHexString(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < bytes.length; i++) {
            String hex = Integer.toHexString(0xFF & bytes[i]);
            if (hex.length() == 1) {
                sb.append('0');
            }
            sb.append(hex);
        }
        return sb.toString();
    }
}
3.运行效果
运行效果如下,图片从网络异步加载并展示在列表中:

第一次进入到页面的时候,图片从服务端下载,并存入到内存和硬盘缓存中,日志如下:
11-26 18:51:15.866: I/ImageListAdapter(11978): cachePath = /storage/sdcard0/Android/data/com.qunar.home/cache
11-26 18:51:16.166: I/ImageListAdapter(11978): download bitmap http://192.168.1.105:8080/qserver/img/3.jpgfrom server.
11-26 18:51:16.166: I/ImageListAdapter(11978): add bitmap http://192.168.1.105:8080/qserver/img/3.jpgto memory cache.
11-26 18:51:16.166: I/ImageListAdapter(11978): add bitmap http://192.168.1.105:8080/qserver/img/3.jpgto disk cache.
11-26 18:51:16.166: I/ImageListAdapter(11978): get bitmap http://192.168.1.105:8080/qserver/img/3.jpgfrom disk cache.
11-26 18:51:16.176: I/ImageListAdapter(11978): get bitmap http://192.168.1.105:8080/qserver/img/3.jpgfrom disk cache.
11-26 18:51:16.196: I/ImageListAdapter(11978): get bitmap http://192.168.1.105:8080/qserver/img/3.jpgfrom memeory cache.
11-26 18:51:16.256: I/ImageListAdapter(11978): get bitmap http://192.168.1.105:8080/qserver/img/3.jpgfrom memeory cache.
11-26 18:51:16.316: I/ImageListAdapter(11978): download bitmap http://192.168.1.105:8080/qserver/img/2.jpgfrom server.
11-26 18:51:16.316: I/ImageListAdapter(11978): add bitmap http://192.168.1.105:8080/qserver/img/2.jpgto memory cache.
再次进入到页面的时候,直接从硬盘缓存中获取图片,并再次存入内存缓存中,日志如下:
11-26 18:53:19.156: I/ImageListAdapter(11978): get bitmap http://192.168.1.105:8080/qserver/img/2.jpgfrom disk cache.
11-26 18:53:19.156: I/ImageListAdapter(11978): add bitmap http://192.168.1.105:8080/qserver/img/2.jpgto memory cache.
11-26 18:53:19.156: I/ImageListAdapter(11978): get bitmap http://192.168.1.105:8080/qserver/img/2.jpgfrom memeory cache.
11-26 18:53:19.196: I/ImageListAdapter(11978): get bitmap http://192.168.1.105:8080/qserver/img/2.jpgfrom memeory cache.
11-26 18:53:19.196: I/ImageListAdapter(11978): get bitmap http://192.168.1.105:8080/qserver/img/3.jpgfrom disk cache.
11-26 18:53:19.196: I/ImageListAdapter(11978): add bitmap http://192.168.1.105:8080/qserver/img/3.jpgto memory cache.
11-26 18:53:19.236: I/ImageListAdapter(11978): get bitmap http://192.168.1.105:8080/qserver/img/2.jpgfrom memeory cache.
11-26 18:53:19.296: I/ImageListAdapter(11978): get bitmap http://192.168.1.105:8080/qserver/img/3.jpgfrom memeory cache.
在页面中滑动的时候,从内存缓存中获取图片,日志如下:
11-26 18:52:08.856: I/ImageListAdapter(11978): get bitmap http://192.168.1.105:8080/qserver/img/4.jpgfrom memeory cache.
11-26 18:52:08.866: I/ImageListAdapter(11978): get bitmap http://192.168.1.105:8080/qserver/img/3.jpgfrom memeory cache.
11-26 18:52:08.866: I/ImageListAdapter(11978): get bitmap http://192.168.1.105:8080/qserver/img/3.jpgfrom memeory cache.
11-26 18:52:09.116: I/ImageListAdapter(11978): get bitmap http://192.168.1.105:8080/qserver/img/0.jpgfrom memeory cache.
11-26 18:52:09.116: I/ImageListAdapter(11978): get bitmap http://192.168.1.105:8080/qserver/img/4.jpgfrom memeory cache.
11-26 18:52:09.116: I/ImageListAdapter(11978): get bitmap http://192.168.1.105:8080/qserver/img/5.jpgfrom memeory cache.
查看SD卡,缓存日志文件和缓存文件如下:


journal文件:

libcore.io.DiskLruCache
1
1
1


DIRTY 48c033bd3fefc9e53ea7fbbc9859ac9e
CLEAN 48c033bd3fefc9e53ea7fbbc9859ac9e 5888
READ 48c033bd3fefc9e53ea7fbbc9859ac9e
READ 48c033bd3fefc9e53ea7fbbc9859ac9e
READ 48c033bd3fefc9e53ea7fbbc9859ac9e
READ 48c033bd3fefc9e53ea7fbbc9859ac9e
DIRTY ffd5bab6b5e218aa16015ffb9996ee2b
CLEAN ffd5bab6b5e218aa16015ffb9996ee2b 10242
DIRTY 49516d59023d04bba69b7416c8ba6984
CLEAN 49516d59023d04bba69b7416c8ba6984 6981
DIRTY 1f2f0d2e2d9981a4b4b0c1884b14ea5d
CLEAN 1f2f0d2e2d9981a4b4b0c1884b14ea5d 8759
DIRTY b89836e7ce38a867393ce4b97daccc65
CLEAN b89836e7ce38a867393ce4b97daccc65 14634
4.代码库
QProject:https://github.com/Pengchengxiang/QProject  分支:feature/bitmapcache
QServer:https://github.com/Pengchengxiang/QServer  分支:feature/cache

新技术,新未来!欢迎大家关注“1024工场”微信服务号,时刻关注我们的最新的技术讯息!(甭客气!尽情的扫描或者长按!)


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值