關(guān)于Android相冊實(shí)現(xiàn)的一些經(jīng)驗(yàn)

一、序

我之前發(fā)布了個(gè)圖片加載框架都办,在JCenter關(guān)閉后嫡锌,“閉關(guān)修煉”,想著改好了出個(gè)2.0版本琳钉。
后來覺得僅增加功能和改進(jìn)實(shí)現(xiàn)不夠势木,得補(bǔ)充一下用例。
相冊列表的加載就是很好的用例歌懒,然后在Github找了一圈啦桌,沒有找到滿意的,有的甚至好幾年沒維護(hù)了,于是就自己寫了一個(gè)甫男。

相比于圖片加載且改,相冊加載在Github上要多很多。
圖片加載的input/output比較規(guī)范板驳,不涉及UI布局又跛;
而相冊則不然,幾乎每個(gè)APP都會有自己獨(dú)特的需求若治,有自己的UI風(fēng)格慨蓝。
因此,相冊庫很難做到通用于大部分APP端幼。
我所實(shí)現(xiàn)的這個(gè)也一樣礼烈,并非以實(shí)現(xiàn)通用的相冊組件為目的,而是作為一個(gè)樣例婆跑,以供參考此熬。

二、 需求描述

網(wǎng)上不少相冊的開源庫洽蛀,都是照微信相冊來搭的界面摹迷,我也是跟著這么做吧,要是說涉及侵權(quán)什么的郊供,那些前輩應(yīng)該先比我收到通知……
主要是自己也不會UI設(shè)計(jì)峡碉,不找個(gè)參照對象怕實(shí)現(xiàn)的太難看。
話說回來驮审,要是真的涉及侵權(quán)鲫寄,請聯(lián)系我處理。

相冊所要實(shí)現(xiàn)的功能疯淫,概括來說地来,就是顯示相冊列表,點(diǎn)擊縮略圖選中熙掺,點(diǎn)擊完成結(jié)束選擇未斑,返回選擇結(jié)果。

需求細(xì)節(jié)币绩,包括但不限于以下列表:

  • 實(shí)現(xiàn)目錄列表蜡秽,相冊列表,預(yù)覽頁面缆镣;
  • 支持單選/多選芽突;
  • 支持顯示選擇順序和限定選擇數(shù)量;
  • 支持自定義篩選條件董瞻;
  • 支持自定義目錄排序寞蚌;
  • 支持“原圖”選項(xiàng);
  • 支持再次進(jìn)入相冊時(shí)傳入已經(jīng)選中的圖片/視頻;
  • 支持切換出APP外拍照或刪除照片后挟秤,回到相冊時(shí)自動刷新壹哺;

效果如圖:

三、API設(shè)計(jì)

由于不同的頁面可能需求不一樣煞聪,所以可以將需求參數(shù)封裝到”Request“中斗躏;
對于通用的選項(xiàng),以及相冊組件的全局配置昔脯,可以更封裝到“Config"中啄糙。
而Request/Config最好是用鏈?zhǔn)紸PI去設(shè)置參數(shù),鏈?zhǔn)紸PI尤其適合參數(shù)是“可選項(xiàng)”的場景云稚。

3.1 全局設(shè)置

EasyAlbum.config()
    .setImageLoader(GlideImageLoader)
    .setDefaultFolderComparator { o1, o2 -> o1.name.compareTo(o2.name)}

GlideImageLoader是相冊組件定義的ImageLoader接口的實(shí)現(xiàn)類隧饼。

public interface ImageLoader {
    void loadPreview(MediaData data, ImageView imageView, boolean asBitmap);

    void loadThumbnail(MediaData data, ImageView imageView, boolean asBitmap);
}

不同的APP使用的圖片加載框架不一樣,所以相冊組件最好不要強(qiáng)依賴圖片加載框架静陈,而是暴露接口給調(diào)用者燕雁。
當(dāng)然,對于整個(gè)APP而言鲸拥,不建議定義這樣的ImageLoader類拐格,因?yàn)檎麄€(gè)APP使用圖片加載的地方很多,
要定義這樣類的話刑赶,要么重載很多方法捏浊,要么就是參數(shù)列表很長,也就喪失了鏈?zhǔn)紸PI的優(yōu)點(diǎn)撞叨。

關(guān)于目錄排序金踪,EasyAlbum中定義的默認(rèn)排序是按照更新時(shí)間(取最新的圖片的更新時(shí)間)排序。
上面例子中舉例的是按目錄名排序牵敷。
如果需要某個(gè)目錄(比方說‘Camera’)胡岔,排在“圖片和視頻”之后,可以這樣定義:

private val priorityFolderComparator = Comparator<Folder> { o1, o2 ->
    val priorityFolder = "Camera"
    if (o1.name == priorityFolder) -1
    else if (o2.name == priorityFolder) 1
    else o1.name.compareTo(o2.name)
}

出個(gè)思考題:
如果需要“優(yōu)先排序”的不只一個(gè)目錄枷餐,比如希望“Camera"第一優(yōu)先靶瘸,"Screenshots"第二優(yōu)先,“Pictures"第三優(yōu)先……
改如何定義Comparator毛肋?

3.2 啟動相冊

EasyAlbum啟動相冊以from起頭奕锌,以start結(jié)束。

EasyAlbum.from(this)
    .setFilter(TestMediaFilter(option))
    .setSelectedLimit(selectLimit)
    .setOverLimitCallback(overLimitCallback)
    .setSelectedList(mediaAdapter?.getData())
    .setAllString(option.text)
    .enableOriginal()
    .start { result ->
        mediaAdapter?.setData(result.selectedList)
    }

具體到實(shí)現(xiàn)村生,就是from返回 Request, Request的start方法啟動相冊頁(AlbumActivity)。

public class EasyAlbum {
    public static AlbumRequest from(@NonNull Context context) {
        return new AlbumRequest(context);
    }
}
public final class AlbumRequest {
    private WeakReference<Context> contextRef;

    AlbumRequest(Context context) {
        this.contextRef = new WeakReference<>(context);
    }
    
    // ...其他參數(shù)..

    public void start(ResultCallback callback) {
        Session.init(this, callback, selectedList);
        if (contextRef != null) {
            Context context = contextRef.get();
            if (context != null) {
                context.startActivity(new Intent(context, AlbumActivity.class));
            }
            contextRef = null;
        }
    }
}

啟動AlbumActivity饼丘,就涉及傳參和結(jié)果返回趁桃。
有兩種思路:

  1. 通過intent傳參數(shù)到AlbumActivity, 用startActivityForResult啟動,通過onActivityResult接收。
  2. 通過靜態(tài)變量傳遞參數(shù)卫病,通過Callback回調(diào)結(jié)果油啤。

第一種方法,需要所有的參數(shù)都能放入Intent, 基礎(chǔ)數(shù)據(jù)可以傳蟀苛,自定義數(shù)據(jù)類可以實(shí)現(xiàn)Parcelable,
但那對于接口的實(shí)現(xiàn)益咬,就沒辦法放 intent 了,到頭來還是要走靜態(tài)變量帜平。
因此幽告,干脆就都走靜態(tài)變量傳遞好了。
這個(gè)方案可行的前提是裆甩, AlbumActivity是封閉的冗锁,不會在跳轉(zhuǎn)其他Activity。
在這個(gè)前提下嗤栓,App不會同一個(gè)時(shí)刻打開多個(gè)AlbumActivity冻河,不需要擔(dān)心共享變量相互干擾的情況。
然后就是茉帅,在Activity結(jié)束時(shí)叨叙,做好清理工作。
可以將“啟動相冊-選擇圖片-結(jié)束相冊”抽象為一次“Session”, 在相冊結(jié)束時(shí)堪澎,執(zhí)行一下clear操作擂错。

final class Session {
    static AlbumRequest request;
    static AlbumResult result;
    private static ResultCallback resultCallback;

    static void init(AlbumRequest req, ResultCallback callback, List<MediaData> selectedList) {
        request = req;
        resultCallback = callback;
        result = new AlbumResult();
        if (selectedList != null) {
            result.selectedList.addAll(selectedList);
        }
    }

    static void clear() {
        if (request != null) {
            request.clear();
            request = null;
            resultCallback = null;
            result = null;
        }
    }
}

四、媒體文件加載

媒體文件加載似乎很簡答全封,就調(diào)ContentResolver query一下的事马昙,但要做到盡量完備,需要考慮的細(xì)節(jié)還是不少的刹悴。

4.1 MediaStore API

查詢媒體數(shù)據(jù)庫行楞,需走ContentResolver的qurey方法:

public final Cursor query( 
  Uri uri,
  String[] projection, 
  String selection, 
  String[] selectionArgs, 
  String sortOrder,
  CancellationSignal cancellationSignal) {
}

媒體數(shù)據(jù)庫記錄了各種媒體類型,要過濾其中的“圖片”和“視頻”土匀,有兩種方法:

1子房、用SDK定義好的MediaStore.Video和MediaStore.Images的Uri。

MediaStore.Video.Media.EXTERNAL_CONTENT_URI
MediaStore.Images.Media.EXTERNAL_CONTENT_URI

2就轧、直接讀取"content://external", 通過MEDIA_TYPE字段過濾证杭。

private static final Uri CONTENT_URI = MediaStore.Files.getContentUri("external");

private static final String TYPE_SELECTION = "(" + MediaStore.Files.FileColumns.MEDIA_TYPE + "="
        + MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO
        + " OR " + MediaStore.Files.FileColumns.MEDIA_TYPE + "="
        + MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE
        + ")";

如果需要同時(shí)讀取圖片和視頻,第2種方法更省事一些妒御。

至于查詢的字段解愤,視需求而定。
以下是比較常見的字段:

private static final String[] PROJECTIONS = new String[]{
        MediaStore.MediaColumns._ID,
        MediaStore.MediaColumns.DATA,
        MediaStore.Files.FileColumns.MEDIA_TYPE,
        MediaStore.MediaColumns.DATE_MODIFIED,
        MediaStore.MediaColumns.MIME_TYPE,
        MediaStore.Video.Media.DURATION,
        MediaStore.MediaColumns.SIZE,
        MediaStore.MediaColumns.WIDTH,
        MediaStore.MediaColumns.HEIGHT,
        MediaStore.Images.Media.ORIENTATION
};

DURATION, SIZE, WIDTH, HEIGHT乎莉,ORIENTATION等字段有可能是無效的(0或者null),
如果是無效的送讲,可以去從文件本身獲取奸笤,但讀文件比較耗時(shí),
所以可以先嘗試從MediaStore讀取哼鬓,畢竟是都訪問到這條記錄了监右,從空間局部原理來說便斥,讀取這些字段是順便的事情捧请,代價(jià)要比另外讀文件本身低很多桃熄。
當(dāng)然腾誉,如果確實(shí)不需要這些信息畴栖,可以直接不讀取胀莹。

4.2 數(shù)據(jù)包裝

數(shù)據(jù)查詢出來荒澡,需要定義Entity來包裝數(shù)據(jù)橡淆。

public final class MediaData implements Comparable<MediaData> {
    private static final String BASE_VIDEO_URI = "content://media/external/video/media/";
    private static final String BASE_IMAGE_URI = "content://media/external/images/media/";

    static final byte ROTATE_UNKNOWN = -1;
    static final byte ROTATE_NO = 0;
    static final byte ROTATE_YES = 1;

    public final boolean isVideo;
    public final int mediaId;
    public final String parent;
    public final String name;
    public final long modifiedTime; // in seconds
    public String mime;

    long fileSize;
    int duration;
    int width;
    int height;
    byte rotate = ROTATE_UNKNOWN;

    public String getPath() {
        return parent + name;
    }

    public Uri getUri() {
        String baseUri = isVideo ? BASE_VIDEO_URI : BASE_IMAGE_URI;
        return Uri.parse(baseUri + mediaId);
    }

    public int getRealWidth() {
        if (rotate == ROTATE_UNKNOWN || width == 0 || height == 0) {
            fillData();
        }
        return rotate != ROTATE_YES ? width : height;
    }

    public int getRealHeight() {
        if (rotate == ROTATE_UNKNOWN || width == 0 || height == 0) {
            fillData();
        }
        return rotate != ROTATE_YES ? height : width;
    }

    // ......
}

4.2.1 數(shù)據(jù)共享

字段的定義中予跌,沒有直接定義path字段搏色,而是定義了parent和name,因?yàn)閳D片/視頻文件可能有成千上萬個(gè)券册,但是目錄大概率不會超過3位數(shù)频轿,所以,我們可以通過復(fù)用parent來節(jié)約內(nèi)存烁焙。
同理航邢,mime也可以復(fù)用。

截取部分查詢的代碼:

int count = cursor.getCount();
List<MediaData>  list = new ArrayList<>(count);
while (cursor.moveToNext()) {
    String path = cursor.getString(IDX_DATA);
    String parent = parentPool.getOrAdd(Utils.getParentPath(path));
    String name = Utils.getFileName(path);
    String mime = mimePool.getOrAdd(cursor.getString(IDX_MIME_TYPE));
     // ......
}

復(fù)用字符串骄蝇,可以用HashMap來做膳殷,我這邊是仿照HashMap寫了一個(gè)專用的類來實(shí)現(xiàn)。
getOrAdd方法九火,傳入一個(gè)字符串赚窃,如果容器中已經(jīng)有這個(gè)字符串,返回容器保存的字符串岔激,
否則勒极,保存當(dāng)前字符串并返回。
如此虑鼎,所有的MediaData共用相同parent和mime字符串對象辱匿。

4.2.2 處理無效數(shù)據(jù)

前面提到,從MediaStore讀取的數(shù)據(jù)炫彩,有部分是無效的匾七,
因此,這些可能無效的字段不要直接public, 而是提供get方法江兢,并在返回之前檢查數(shù)據(jù)的有效性昨忆,如果數(shù)據(jù)無效則讀文件獲取數(shù)據(jù)。
當(dāng)然杉允,讀文件是耗時(shí)操作邑贴,雖然一般情況下時(shí)間是可控的限府,但是最好還是放IO線程去訪問比較保險(xiǎn)。

也有比較折中的做法:

  1. 數(shù)據(jù)只是用作參考痢缎,有的話更好,沒有也沒關(guān)系世澜。
    如果是這樣的話独旷,提供不做檢查直接返回?cái)?shù)據(jù)的方法:
    public int getWidth() {
        return rotate != ROTATE_YES ? width : height;
    }

    public int getHeight() {
        return rotate != ROTATE_YES ? height : width;
    }
  1. 數(shù)據(jù)比較重要,但也不至于沒有就不行寥裂。
    這種case嵌洼,當(dāng)數(shù)據(jù)無效時(shí),可以嘗試是讀取封恰,但是加個(gè)timeout, 在規(guī)定時(shí)間內(nèi)沒有完成讀取則直接返回麻养。
    public int getDuration() {
        if (isVideo && duration == 0) {
            checkData();
        }
        return duration;
    }

    void checkData() {
        if (!hadFillData) {
            FutureTask<Boolean> future = new FutureTask<>(this::fillData);
            try {
                // Limit the time for filling extra info, in case of ANR.
                AlbumConfig.getExecutor().execute(future);
                future.get(300, TimeUnit.MILLISECONDS);
            } catch (Throwable ignore) {
            }
        }
    }

4.3 數(shù)據(jù)加載

數(shù)據(jù)加載部分是最影響相冊體驗(yàn)的因素之一。
等待時(shí)間诺舔、數(shù)據(jù)刷新鳖昌,數(shù)據(jù)有效性等都會影響相冊的交互。

4.3.1 緩存MediaData

媒體庫查詢是一個(gè)綜合IO讀取和CPU密集計(jì)算的操作低飒,文件少的時(shí)候還好许昨,一旦文件比較多,耗時(shí)幾秒鐘也是有的褥赊。
如果用戶每次打開相冊都要等幾秒鐘才刷出數(shù)據(jù)糕档,那體驗(yàn)就太糟糕了。
加個(gè)MediaData的緩存拌喉,再次進(jìn)入相冊時(shí)速那,就不需要再次讀所有字段了,
只需讀取MediaStore的ID字段尿背,然后結(jié)合緩存端仰,做下Diff, 已刪除的移除出緩存,新增的根據(jù)ID檢索其記錄残家,創(chuàng)建MediaData添加到緩存榆俺。
再次進(jìn)入相冊,即使有增刪也不會太多坞淮。

緩存MediaData的好處不僅僅是加速再次查詢MediaStore茴晋,還可以減少對象的創(chuàng)建,不需要每次查詢都重新創(chuàng)建MediaData對象回窘;
另外诺擅,前面也提到,MediaData部分字段有的是無效的啡直,在無效時(shí)需要讀取原文件獲取烁涌,緩存MediaData可免去再次讀文件獲取數(shù)據(jù)的時(shí)間(如果對象是讀取MediaStore重新創(chuàng)建的苍碟,就又回到無效的狀態(tài)了)。

還有就是撮执,有緩存的話微峰,就可以做preload了。
當(dāng)然這個(gè)得看APP是否有這個(gè)需求抒钱,如果APP是媒體相關(guān)的蜓肆,大概率要訪問相冊的,可以考慮preload谋币。

做緩存的代價(jià)就是要占用些內(nèi)存仗扬,這也是前面MediaData為什么復(fù)用parent和mime的原因。
緩存是空間換時(shí)間蕾额,復(fù)用對象是時(shí)間換空間早芭,總體而言這個(gè)對沖是賺的,因?yàn)樽x取IO更耗時(shí)诅蝶。
另外退个,如果有必要,可以提供clearCache接口秤涩,在適當(dāng)?shù)臅r(shí)機(jī)清空緩存帜乞。

4.3.2 組裝結(jié)果

相冊的UI層需要是根據(jù)Request的查詢條件過濾后的MediaData, 并以目錄為分組,按更新時(shí)間降序排列的數(shù)據(jù)筐眷。
緩存的MediaData并非查詢的終點(diǎn)黎烈,但卻提供了一個(gè)好的起點(diǎn)。
在有緩存好的MediaData列表的前提下匀谣,可直接根據(jù)MediaData列表做過濾照棋,排序和分組,
而不需要每次都將過濾條件拼接SQL到數(shù)據(jù)庫中查詢武翎,而且相比于拼接SQL烈炭,在上層直接根據(jù)MediaData過濾要更加靈活。

下面是EasyAlbum基于MediaData緩存的查詢:

private static List<Folder> makeResult(AlbumRequest request) {
        AlbumRequest.MediaFilter filter = request.filter;
        ArrayList<MediaData> totalList = new ArrayList<>(mediaCache.size());

        if (filter == null) {
            totalList.addAll(mediaCache.values());
        } else {
              // 根據(jù)filter過濾MediaData
            for (MediaData item : mediaCache.values()) {
                if (filter.accept(item)) {
                    totalList.add(item);
                }
            }
        }

        // 先對所有MediaData排序宝恶,后面分組后就不需要繼續(xù)在分組內(nèi)排序了
        // 因?yàn)榉纸M時(shí)是按順序放到分組列表的符隙。
        Collections.sort(totalList);

        Map<String, ArrayList<MediaData>> groupMap = new HashMap<>();
        for (MediaData item : totalList) {
            String parent = item.parent;
            ArrayList<MediaData> subList = groupMap.get(parent);
            if (subList == null) {
                subList = new ArrayList<>();
                groupMap.put(parent, subList);
            }
            subList.add(item);
        }

        final List<Folder> result = new ArrayList<>(groupMap.size() + 1);
        for (Map.Entry<String, ArrayList<MediaData>> entry : groupMap.entrySet()) {
            String folderName = Utils.getFileName(entry.getKey());
            result.add(new Folder(folderName, entry.getValue()));
        }

        // 對目錄排序
        Collections.sort(result, request.folderComparator);

        // 最后,總列表放在最前
        result.add(0, new Folder(request.getAllString(), totalList));
        return result;
    }

MediaFilter的定義如下:

public interface MediaFilter {
    boolean accept(MediaData media);

    // To identify the filter
    String tag();
}

基于MediaData緩存列表的查詢雖然比基于數(shù)據(jù)庫的查詢快不少垫毙,但是當(dāng)文件很多時(shí)霹疫,也還是要花一些時(shí)間的。
所以我們可以再加一個(gè)緩存:緩存最終結(jié)果综芥。
再加一個(gè)結(jié)果緩存丽蝎,只是增加了些容器,容器指向的對象(MediaData)是之前MediaData緩存列表所引用的對象膀藐,所以代價(jià)還好屠阻。
再次進(jìn)入相冊時(shí)红省,可以先直接取結(jié)果顯示,然后再去檢查MediaStore相對于緩存有沒有變更国觉,有則刷新緩存和UI吧恃,否則直接返回。
APP可能有多個(gè)地方需要相冊麻诀,不同地方查詢條件可能不一樣蚜枢,所以MediaFilter定義了tag接口,用來區(qū)分不同的查詢针饥。

4.3.3 加載流程

流程圖如下:
注意,以上的“結(jié)果”是提供給相冊頁面顯示的數(shù)據(jù)需频,并非相冊返回給調(diào)用者的“已選中的媒體”丁眼。

做了兩層緩存,加載流程是會復(fù)雜一些的昭殉。
但好處也是顯而易見的苞七,增加了結(jié)果緩存之后,再次啟動相冊就基本是“秒開”了挪丢。
查詢過程是在后臺線程中執(zhí)行的蹂风,結(jié)果通過handler發(fā)送給AlbumActivity。

圖中還有一些小處理沒畫出來乾蓬。
比如惠啄,首次加載,在發(fā)送結(jié)果給相冊界面之后任内,還會繼續(xù)執(zhí)行一個(gè)“檢查文件是否已刪除”的操作撵渡。
針對的是這么一種情況:MediaStore中的記錄,DATA字段所對應(yīng)的文件不存在死嗦。
我自己的設(shè)備上是沒有出現(xiàn)過這種case, 我也是聽前輩講的趋距,或許他們遇到過。
如果確實(shí)有設(shè)備存在這樣的情況越除,的確應(yīng)該檢查一下节腐,否則相冊滑動到這些“文件不存在”的記錄時(shí),會只看到一片黑摘盆,稍微影響體驗(yàn)翼雀。
但由于我自己沒有具體考證,所以在EasyAblum的全局配置中留了option, 可以設(shè)置不執(zhí)行骡澈。
關(guān)于這點(diǎn)大家按具體情況自行評估锅纺。

加載流程一般在進(jìn)入相冊頁時(shí)啟動。
考慮到用戶在瀏覽相冊時(shí)肋殴,有時(shí)候可能會切換出去拍照或者刪除照片囤锉,
可以在onResume的時(shí)候也啟動一下加載流程坦弟,檢查是否有媒體文件增刪。

五官地、相冊列表

5.1 媒體縮略圖

Android系統(tǒng)對相冊文件提供了獲取縮略圖的API酿傍,通過該API獲取圖片要比直接讀取媒體文件本身要快很多。
一些圖片加載框架中有實(shí)現(xiàn)相關(guān)邏輯驱入,比如Glide的實(shí)現(xiàn)了MediaStoreImageThumbLoader和MediaStoreVideoThumbLoader赤炒,但是所用API比較舊,在我的設(shè)備(Android 10)上已經(jīng)不生效了亏较。
如果使用Glide的朋友可以自行實(shí)現(xiàn)ModelLoader和ResourceDecoder來處理莺褒。
EasyAlbum的Demo中有實(shí)現(xiàn),感興趣的朋友可以參考一下雪情。

5.2 列表布局

相冊列表的item通常是正方形遵岩,如果用RecycleView布局,最好能讓每一列都等寬巡通。
下面這個(gè)ItemDecoration的實(shí)現(xiàn)是其中一種方法:

public class GridItemDecoration extends RecyclerView.ItemDecoration {
    private final int n; // 列的數(shù)量
    private final int space; // 列與列之間的間隔
    private final int part; // 每一列應(yīng)該分?jǐn)偠嗌匍g隔

    public GridItemDecoration(int n, int space) {
        this.n = n;
        this.space = space;
        // 總間隔:space * (n - 1) 尘执,等分n份
        part = space * (n - 1) / n;
    }

    @Override
    public void getItemOffsets(
            @NonNull Rect outRect,
            @NonNull View view,
            @NonNull RecyclerView parent,
            @NonNull RecyclerView.State state) {
        int position = parent.getChildLayoutPosition(view);
        int column = position % n;
        outRect.left = Math.round(part * column / (float) (n - 1));
        outRect.right = part - outRect.left;
        outRect.top = 0;
        outRect.bottom = space;
    }
}

起原理就是將所有space加起來,等分為n份宴凉,每個(gè)item分?jǐn)?份誊锭。
其中第i列(index從0開始)的左邊部分的間隔的計(jì)算公式為:space * i / n 。
比方說colomn = 4, 那么就有3個(gè)space; 如果每個(gè)space=4px, 則每個(gè)item分?jǐn)? * (4-1)/ 4 = 3px弥锄。
第1個(gè)item, left=0px, right = 3px丧靡;
第2個(gè)item, left=1px, right = 2px;
第3個(gè)item, left=2px, right =1px籽暇;
第4個(gè)item, left=3px, right =0px窘行。
于是,每個(gè)間隔看起來都是4px, 且每個(gè)item的left+right都是相等的图仓,所以留給view的寬度是相等的罐盔。
效果如下圖:

有的地方是這么去分配left和right的:

        outRect.left = column == 0 ? 0 : space / 2;
        outRect.right = column == (n - 1) ? 0 : space / 2;

這樣能讓每個(gè)間隔的大小相等,但是view本身的寬度就不相等了救崔。
效果如下圖:

左右兩個(gè)item分別比中間的item多了2px惶看。
這2px看上去不多,但是可能會導(dǎo)致列表變更(增刪)時(shí)六孵,圖片框架的緩存失效纬黎。
例如:
如果刪除了最接近的一張照片,原第2-4列會移動到1-3列劫窒,原第1列會移動到第4列本今。
于是第2列的寬度從266變?yōu)?88,第4列的寬度從288變?yōu)?66,
而圖片加載框架的target寬高是緩存key的計(jì)算要素之一冠息,寬度變了挪凑,就不能命中之前的緩存了。

六逛艰、后序

相冊的實(shí)現(xiàn)可簡單可復(fù)雜躏碳,我見過的最簡單的是直接在主線程查詢媒體數(shù)據(jù)庫的……
本文從各個(gè)方面分享了一些相冊實(shí)現(xiàn)的經(jīng)驗(yàn),尤其是相冊加載部分散怖。
目前這個(gè)時(shí)代菇绵,手機(jī)存幾千上萬張圖片是很常見的,優(yōu)化好相冊的加載镇眷,能提升不少用戶體驗(yàn)咬最。

項(xiàng)目已發(fā)布到 Github 和 Maven Central:

Githun地址:
https://github.com/BillyWei01/EasyAlbum

下載方式:

implementation 'io.github.billywei01:easyalbum:1.0.6'

歡迎各位朋友一鍵三連!
哦不欠动,Github沒有一鍵三連丹诀。
歡迎各位朋友star, folk, 提issue/PR!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末翁垂,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子硝桩,更是在濱河造成了極大的恐慌沿猜,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,544評論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件碗脊,死亡現(xiàn)場離奇詭異啼肩,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)衙伶,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,430評論 3 392
  • 文/潘曉璐 我一進(jìn)店門祈坠,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人矢劲,你說我怎么就攤上這事赦拘。” “怎么了芬沉?”我有些...
    開封第一講書人閱讀 162,764評論 0 353
  • 文/不壞的土叔 我叫張陵躺同,是天一觀的道長。 經(jīng)常有香客問我丸逸,道長蹋艺,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,193評論 1 292
  • 正文 為了忘掉前任黄刚,我火速辦了婚禮捎谨,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己涛救,他們只是感情好畏邢,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,216評論 6 388
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著州叠,像睡著了一般棵红。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上咧栗,一...
    開封第一講書人閱讀 51,182評論 1 299
  • 那天逆甜,我揣著相機(jī)與錄音,去河邊找鬼致板。 笑死交煞,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的斟或。 我是一名探鬼主播素征,決...
    沈念sama閱讀 40,063評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼萝挤!你這毒婦竟也來了御毅?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,917評論 0 274
  • 序言:老撾萬榮一對情侶失蹤怜珍,失蹤者是張志新(化名)和其女友劉穎端蛆,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體酥泛,經(jīng)...
    沈念sama閱讀 45,329評論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡今豆,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,543評論 2 332
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了柔袁。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片呆躲。...
    茶點(diǎn)故事閱讀 39,722評論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖捶索,靈堂內(nèi)的尸體忽然破棺而出插掂,到底是詐尸還是另有隱情,我是刑警寧澤腥例,帶...
    沈念sama閱讀 35,425評論 5 343
  • 正文 年R本政府宣布燥筷,位于F島的核電站,受9級特大地震影響院崇,放射性物質(zhì)發(fā)生泄漏肆氓。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,019評論 3 326
  • 文/蒙蒙 一底瓣、第九天 我趴在偏房一處隱蔽的房頂上張望谢揪。 院中可真熱鬧蕉陋,春花似錦、人聲如沸拨扶。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,671評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽患民。三九已至缩举,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間匹颤,已是汗流浹背仅孩。 一陣腳步聲響...
    開封第一講書人閱讀 32,825評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留印蓖,地道東北人辽慕。 一個(gè)月前我還...
    沈念sama閱讀 47,729評論 2 368
  • 正文 我出身青樓,卻偏偏與公主長得像赦肃,于是被迫代替她去往敵國和親溅蛉。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,614評論 2 353

推薦閱讀更多精彩內(nèi)容