Android Launcher3中微信联系人快捷方式无法卸载的解决方案

前一片文章讲解了Launcher在应用卸载后是如何删除桌面图标和快捷方式的,有了上篇的基础,想要解决此问题就很容易了。

不了解流程的请看Launcher3应用卸载后桌面图标及快捷方式的删除流程
知道了流程我们还要清楚launcher图标的数据是怎么存储的,存储在哪里?

做过创建快捷方式的需求的同学应该清楚,launcher是把启动图标和快捷方式存储在数据库之中,和其相关的有两个库app_icons.db和launcher.db。app_icons.db中有一张icons的表,里面存储的就是APP的启动图标信息,也就是AndoridManifest.xml中配置了<category android:name="android.intent.category.LAUNCHER"/>信息的所有Activity。icons表中的主要信息是icon,label,componentName,它主要用作图标缓存,对应的操作都在IconCache类中。

创建的快捷方式信息并不在这个库中,它是存储在launcher.db的favorites表中,favorites表中的信息包含icons表中的所有信息,而且文件夹信息也是存储在这个表,launcher桌面显示的所有图标都是从这个表中读取,它存储着图标的显示位置,大小,启动Intent等等各种信息,一条记录对应着一个桌面图标。
应用卸载时launcher会把这两个表中的对应数据删除,然后移除桌面上显示的图标view,这样这个应用就彻底的消失了。

了解了以上知识,就可以直接debug调试,看卸载过程中在那个环节出异常。经过debug调试,我发现在执行 deleteItemsFromDatabase(Context context, final ArrayList<? extends ItemInfo> items)方法删除时,items的size为1,问题找到了,既然创建了一个快捷方式,那么数据库中就有两条记录,一个默认启动图标,一个快捷方式。问题就出在items列表的生成上,根据上一篇文章知道items是两次遍历sBgItemsIdMap获取的,一次是通过ComponentName对象获取包名对比生成的,一次是直接对比ComponentName对象生成的。我们再看看源码:

private static ArrayList<ItemInfo> getItemsByPackageName(final String pn, final UserHandleCompat user) {
    ItemInfoFilter filter  = new ItemInfoFilter() {
        @Override
        public boolean filterItem(ItemInfo parent, ItemInfo info, ComponentName cn) {
            return cn.getPackageName().equals(pn) && info.user.equals(user);//获取包名对比
        }
    };
    return filterItemInfos(sBgItemsIdMap, filter);
}

ArrayList<ItemInfo> getItemInfoForComponentName(final ComponentName cname, final UserHandleCompat user) {
    ItemInfoFilter filter  = new ItemInfoFilter() {
        @Override
        public boolean filterItem(ItemInfo parent, ItemInfo info, ComponentName cn) {
            if (info.user == null) {//直接对比对象
                return cn.equals(cname);
            } else {
                return cn.equals(cname) && info.user.equals(user);
            }
        }
    };
    return filterItemInfos(sBgItemsIdMap, filter);
}

static ArrayList<ItemInfo> filterItemInfos(Iterable<ItemInfo> infos, ItemInfoFilter f) {
    HashSet<ItemInfo> filtered = new HashSet<ItemInfo>();
    for (ItemInfo i : infos) {
        if (i instanceof ShortcutInfo) {
            ShortcutInfo info = (ShortcutInfo) i;
            ComponentName cn = info.getTargetComponent();//关键代码
            if (cn != null && f.filterItem(null, info, cn)) {
                filtered.add(info);
            }
        } else if (i instanceof FolderInfo) {
            FolderInfo info = (FolderInfo) i;
            for (ShortcutInfo s : info.contents) {
                ComponentName cn = s.getTargetComponent();//关键代码
                if (cn != null && f.filterItem(info, s, cn)) {
                    filtered.add(s);
                }
            }
        } else if (i instanceof LauncherAppWidgetInfo) {
            LauncherAppWidgetInfo info = (LauncherAppWidgetInfo) i;
            ComponentName cn = info.providerName;
            if (cn != null && f.filterItem(null, info, cn)) {
                filtered.add(info);
            }
        }
    }
    return new ArrayList<ItemInfo>(filtered);
}

从源码中我们可以看出,关键逻辑就在于ComponentName对象,两种过滤条件都需要ComponentName对象,如果ComponentName对象为null则直接跳过过滤逻辑,那有没有可能ComponentName对象为null,有些快捷方式是没有ComponentName对象的?经过调试发现,果然微信的联系人快捷方式ComponentName对象是null,为什么会为null呢?我们把数据库导出来看看便知,我导出了launcher.db数据库,查看了favorites表中intent字段,发现微信联系人快捷方式的intent是这样的: #Intent;action=com.tencent.mm.action.BIZSHORTCUT;launchFlags=0x4000000;package=com.tencent.mm;B.LauncherUI.From.Biz.Shortcut=true;S.app_shortcut_custom_id=shortcut_c3b777c2bac283c2aac286;S.LauncherUI.Shortcut.Username=shortcut_c3b777c2bac283c2aac286;end

一个正常的是这样的: #Intent;action=android.intent.action.MAIN;category=android.intent.category.LAUNCHER;launchFlags=0x10200000;component=com.tencent.mm/.ui.LauncherUI;sourceBounds=360%20512%20534%20736;l.profile=0;end

这是把Intent转成String的存储方式,我们来看看它俩的区别,联系人快捷方式中的数据有:action,launchFlags,package以及Bundle数据,B.xxx,S.xxx等是Bundle数据,比如B.LauncherUI.From.Biz.Shortcut=trueB代表的是boolean型,LauncherUI.From.Biz.Shortcut是key,true是value。S是表示String类型,其他的基本类型类似。

一个正常的启动图标的数据有:action,category,launchFlags,component,sourceBounds,Bundle数据。两数据相同的有action,launchFlags,Bundle数据,但是联系人快捷方式多了package,启动图标多了category和component。到此问题一目了然了,联系人快捷方式中根本没有component数据,那么必然为null。前面提到第一种对比方式是获取到ComponentName对象然后获取包名进行对比,既然可以通过包名对比,联系人快捷方式不正好有package字段么?于是我在deletePackageFromDatabase方法中增加了直接对比包名过滤的逻辑:

static void deletePackageFromDatabase(Context context, final String pn, final UserHandleCompat user) {
    //老的过滤方式不变
    ArrayList<ItemInfo> itemsIdList = getItemsByPackageName(pn, user);

    //根据包名遍历sBgItemsIdMap寻找程序自己创建的快捷方式,以微信为例,微信创建的联系人做面图标格式为:
    // #Intent;action=com.tencent.mm.action.BIZSHORTCUT;launchFlags=0x4000000;
    // package=com.tencent.mm;B.LauncherUI.From.Biz.Shortcut=true;S.app_shortcut_custom_id=shortcut_c3a07ec2a8c398c3bdc39f78;
    // S.LauncherUI.Shortcut.Username=shortcut_c3a07ec2a8c398c3bdc39f78;end
    for (ItemInfo i : sBgItemsIdMap) {
        if (i instanceof ShortcutInfo) {
            ShortcutInfo info = (ShortcutInfo) i;
            String name = info.getPackageName();//有些快捷方式是没有ComponentName的,比如微信联系人图标
            if (!TextUtils.isEmpty(name) && name.equals(pn)) {
                itemsIdList.add(info);
            }
        }
    }
    deleteItemsFromDatabase(context, itemsIdList);
}

//ShortcutInfo.getPackageName()方法是我自增的,原来ShortcutInfo里面是没有的:
public String getPackageName() {
    ComponentName component = getTargetComponent();
    return component != null ? component.getPackageName() :
            (promisedIntent != null ? promisedIntent.getPackage() : intent.getPackage());
}

这样修改后数据库和缓存中的数据就被删除了,但是这样还不够,数据是被删除了,但是这里并没有通知launcher界面刷新,也没有从launcher界面上删除对应的view。数据虽然删除了,但是桌面图标依然存在。接下来我们看launcher中的view如何删除:

private class PackageUpdatedTask implements Runnable {
    .........
    public void run() {
    .........
        // Remove any queued items from the install queue
        InstallShortcutReceiver.removeFromInstallQueue(context, removedPackageNames, mUser);
        // Call the components-removed callback
        mHandler.post(new Runnable() {
            public void run() {
                Callbacks cb = getCallback();
                if (callbacks == cb && cb != null) {
                    callbacks.bindComponentsRemoved(
                            removedPackageNames, removedApps, mUser, removeReason);
                }
            }
        });
    ..........
    }
}

这一块在上篇已经讲过,在LauncherModel的内部类PackageUpdatedTask的run方法中有一个callbacks,它执行了bindComponentsRemoved回调,我们来看看它的实现,它的实现在Launcher类中。

@Override
public void bindComponentsRemoved(final ArrayList<String> packageNames,
        final ArrayList<AppInfo> appInfos, final UserHandleCompat user, final int reason) {
        .......
        HashSet<ComponentName> removedComponents = new HashSet<ComponentName>();
            for (AppInfo info : appInfos) {
                removedComponents.add(info.componentName);
            }
        if (!packageNames.isEmpty()) {//根据包名删除
            mWorkspace.removeItemsByPackageName(packageNames, user);
        }
        if (!removedComponents.isEmpty()) {//根据ComponentName删除
            mWorkspace.removeItemsByComponentName(removedComponents, user);
        }
        .......
}

实现方法调用了Workspace的removeItemsByPackageNameremoveItemsByComponentName方法执行的删除操作,很熟悉的味道,前面删除数据也是这样的一个形式,分别根据包名和ComponentName对象去删除,我们已经知道微信联系人的快捷方式是没有ComponentName对象的,所以我们直接看根据包名删除方法removeItemsByPackageName

void removeItemsByPackageName(final ArrayList<String> packages, final UserHandleCompat user) {
        final HashSet<String> packageNames = new HashSet<String>();
        packageNames.addAll(packages);

        // Filter out all the ItemInfos that this is going to affect
        final HashSet<ItemInfo> infos = new HashSet<ItemInfo>();
        final HashSet<ComponentName> cns = new HashSet<ComponentName>();
        ArrayList<CellLayout> cellLayouts = getWorkspaceAndHotseatCellLayouts();
        for (CellLayout layoutParent : cellLayouts) {
            ViewGroup layout = layoutParent.getShortcutsAndWidgets();
            int childCount = layout.getChildCount();
            for (int i = 0; i < childCount; ++i) {
                View view = layout.getChildAt(i);
                infos.add((ItemInfo) view.getTag());
            }
        }
        LauncherModel.ItemInfoFilter filter = new LauncherModel.ItemInfoFilter() {
            @Override
            public boolean filterItem(ItemInfo parent, ItemInfo info,
                                      ComponentName cn) {
                if (packageNames.contains(cn.getPackageName())
                        && info.user.equals(user)) {
                    cns.add(cn);//根据ComponentName对象获取包名对比生成ComponentName集合cns
                    return true;
                }
                return false;
            }
        };
        LauncherModel.filterItemInfos(infos, filter);
        // Remove the affected components
        removeItemsByComponentName(cns, user);//根据cns集合删除view

        //新增移除没有ComponentName的桌面图标逻辑@{
        final HashSet<String> pns = new HashSet<String>();
        for (ItemInfo i : infos) {
            if (i instanceof ShortcutInfo) {
                ShortcutInfo info = (ShortcutInfo) i;
                String name = info.getPackageName();//有些快捷方式是没有ComponentName的,比如微信联系人图标
                if (!TextUtils.isEmpty(name) && packageNames.contains(name)) {
                    pns.add(name);
                }
            }
        }
        if(!pns.isEmpty()){
            removeItemsByPackageName(pns, user);
        }
        // }@
    }

它里面是根据包名过滤,生成ComponentName对象集合,然后在调用removeItemsByComponentName方法进行删除,最终都是removeItemsByComponentName执行的删除操作的,然而我们的快捷方式是没有ComponentName对象,现有的逻辑显然无法删除桌面显示的view。所以在后面加了以下一段逻辑,生成一个包名集合,然后调用removeItemsByPackageName方法进行删除。removeItemsByPackageName方法是新增方法,逻辑如下:

/**
 * 根据包名移除桌面图标(快捷方式),比如微信创建的联系人桌面图标
 * @param packageNames 包名集合
 * @param user
 */
void removeItemsByPackageName(final HashSet<String> packageNames,
                                final UserHandleCompat user) {
    ArrayList<CellLayout> cellLayouts = getWorkspaceAndHotseatCellLayouts();
    for (final CellLayout layoutParent: cellLayouts) {
        final ViewGroup layout = layoutParent.getShortcutsAndWidgets();

        final HashMap<ItemInfo, View> children = new HashMap<ItemInfo, View>();
        for (int j = 0; j < layout.getChildCount(); j++) {
            final View view = layout.getChildAt(j);
            ItemInfo tag = (ItemInfo) view.getTag();
            children.put(tag, view);
        }

        final ArrayList<View> childrenToRemove = new ArrayList<View>();
        final HashMap<FolderInfo, ArrayList<ShortcutInfo>> folderAppsToRemove =
                new HashMap<FolderInfo, ArrayList<ShortcutInfo>>();

        for (ItemInfo i : children.keySet()) {
            if (i instanceof FolderInfo) {
                FolderInfo folder = (FolderInfo) i;
                for (ShortcutInfo info : folder.contents) {
                    String pn = info.getPackageName();//获取包名
                    if (packageNames.contains(pn) && info.user.equals(user)) {//通过包名对比
                        ArrayList<ShortcutInfo> appsToRemove;
                        if (folderAppsToRemove.containsKey(folder)) {
                            appsToRemove = folderAppsToRemove.get(folder);
                        } else {
                            appsToRemove = new ArrayList<ShortcutInfo>();
                            folderAppsToRemove.put(folder, appsToRemove);
                        }
                        appsToRemove.add(info);
                    }
                }
            } else if(i instanceof ShortcutInfo){
                ShortcutInfo info = (ShortcutInfo) i;
                String pn = info.getPackageName();//获取包名
                if (packageNames.contains(pn) && info.user.equals(user)) {//通过包名对比
                    childrenToRemove.add(children.get(info));
                }
            }
        }
        // Remove all the apps from their folders
        for (FolderInfo folder : folderAppsToRemove.keySet()) {
            ArrayList<ShortcutInfo> appsToRemove = folderAppsToRemove.get(folder);
            for (ShortcutInfo info : appsToRemove) {
                folder.remove(info);
            }
        }

        // Remove all the other children
        for (View child : childrenToRemove) {
            // Note: We can not remove the view directly from CellLayoutChildren as this
            // does not re-mark the spaces as unoccupied.
            layoutParent.removeViewInLayout(child);
            if (child instanceof DropTarget) {
                mDragController.removeDropTarget((DropTarget) child);
            }
        }

        if (childrenToRemove.size() > 0) {
            layout.requestLayout();
            layout.invalidate();
        }
    }

    // Strip all the empty screens
    stripEmptyScreens();
}

这个方法是完全拷贝removeItemsByComponentName方法,稍加修改,把里面生成childrenToRemove列表的过滤条件改成了通过包名对比。有了childrenToRemove列表就可以顺利的遍历删除对应的view,至此整个应用的启动图标和快捷方式同时删除的操作已经完成。

转载请注明来处:https://www.jianshu.com/p/8ba912ad537e

推荐阅读更多精彩内容