转载请注明出处:https://blog.csdn.net/u012932409/article/details/84975178
写在前面
最近客户有个需求,要求增加操作Setting列表配置项的功能,是不是一脸懵,没关系,一图胜千言,接下来就上图。诺,就是这么个意思。
原来的列表配置项
增加了单个配置项
增加了多个配置项
老铁们看懂了么,就是在原有的列表项中增加客户想要的项,来我给你们分析下,Setting是系统级APP,到时候直接打包进Room里这不用我多说吧。重点来了,如果你把这个功能写死了,那么恭喜你,准备迎接一波又一波的系统打包发更新版本吧。客户今天加个列表项,明天减个列表项啥的,不很正常么(虽然你的内心是一万只奔腾在草原),但还是得乖乖去打升级包。
那么,今天老司机就来带你解决这一烦恼,坐稳了,要发车了。
进入正题
先说下我的思路,广播是个好东西(系统App和其它App直接交换数据或者执行命令什么的,大有用处),快拿小本本记下来,假设要增加单条配置项,广播无疑是首选项,增加和删除都很方便,如果要增加多条配置项,广播就不再适用了,当然你也可以构造复杂的数据集合,通过广播来传递解析也是可以的。
多条配置项,我们将采用xml文件配置的方式(别问我怎么想到的,看了Setting的源码你就知道了),和系统设置一样的节点名称,方便解析和理解。
先献上我的分析过程图(精华都在图里了)
之前说过Hierarchy View是个好扳手,这一次我们依旧使用它来定位Setting的布局文件,搜索过程图我就不贴了,最终根据id我们定位到
settings_main_dashboard.xml 布局,长这样
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/main_content" android:layout_height="match_parent" android:layout_width="match_parent" />
通过查找布局文件的引用,接下来我们跟到了SettingsActivity中
源码位置 packages\apps\Settings\src\com\android\settings\SettingsActivity.java
@Override protected void onCreate(Bundle savedState) { super.onCreate(savedState); ... final ComponentName cn = intent.getComponent(); final String className = cn.getClassName(); //目前显示的就是设置的主界面,mIsShowingDashboard=true mIsShowingDashboard = className.equals(Settings.class.getName()); ... //此处加载的就是刚刚的settings_main_dashboard布局 setContentView(mIsShowingDashboard ? R.layout.settings_main_dashboard : R.layout.settings_main_prefs); //settings_main_dashboard布局中的mContent需要被替换填充 mContent = (ViewGroup) findViewById(R.id.main_content); ... if (!mIsShowingDashboard) { mDisplaySearch = false; // UP will be shown only if it is a sub settings if (mIsShortcut) { mDisplayHomeAsUpEnabled = isSubSettings; } else if (isSubSettings) { mDisplayHomeAsUpEnabled = true; } else { mDisplayHomeAsUpEnabled = false; } setTitleFromIntent(intent); Bundle initialArguments = intent.getBundleExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS); switchToFragment(initialFragmentName, initialArguments, true, false, mInitialTitleResId, mInitialTitle, false); } else {//进入这,通过switchToFragment方法替换 // No UP affordance if we are displaying the main Dashboard mDisplayHomeAsUpEnabled = false; // Show Search affordance mDisplaySearch = true; mInitialTitleResId = R.string.dashboard_title; switchToFragment(DashboardSummary.class.getName(), null, false, false, mInitialTitleResId, mInitialTitle, false); } ... }
接下来到switchToFragment方法中
private Fragment switchToFragment(String fragmentName, Bundle args, boolean validate, boolean addToBackStack, int titleResId, CharSequence title, boolean withTransition) { if (validate && !isValidFragment(fragmentName)) { throw new IllegalArgumentException("Invalid fragment for this activity: " + fragmentName); } Fragment f = Fragment.instantiate(this, fragmentName, args); FragmentTransaction transaction = getFragmentManager().beginTransaction(); //通过DashboardSummary来替换id为main_content的FrameLayout transaction.replace(R.id.main_content, f); if (withTransition) { TransitionManager.beginDelayedTransition(mContent); } if (addToBackStack) { transaction.addToBackStack(SettingsActivity.BACK_STACK_PREFS); } if (titleResId > 0) { transaction.setBreadCrumbTitle(titleResId); } else if (title != null) { transaction.setBreadCrumbTitle(title); } transaction.commitAllowingStateLoss(); getFragmentManager().executePendingTransactions(); return f; }
到这里我们找到了Setting主界面显示的真正内容是DashboardSummary这个类,跳到这个类,让我们来一探究竟
源码位置 packages\apps\Settings\src\com\android\settings\dashboard\DashboardSummary.java
首先看到onResume()方法中
@Override public void onResume() { super.onResume(); //方法名和UI相关,应该是我们要找的 sendRebuildUI(); //应用删除、改变、替换广播监听,猜想应该是和设置中应用项的内层有关 final IntentFilter filter = new IntentFilter(Intent.ACTION_PACKAGE_ADDED); filter.addAction(Intent.ACTION_PACKAGE_REMOVED); filter.addAction(Intent.ACTION_PACKAGE_CHANGED); filter.addAction(Intent.ACTION_PACKAGE_REPLACED); filter.addDataScheme("package"); getActivity().registerReceiver(mHomePackageReceiver, filter); }
sendRebuildUI()方法通过Handler发送一个MSG_REBUILD_UI消息,找到消息接收地方
private Handler mHandler = new Handler() { @Override public void handleMessage(Message msg) { switch (msg.what) { case MSG_REBUILD_UI: { final Context context = getActivity(); rebuildUI(context); } break; } } }; private HomePackageReceiver mHomePackageReceiver = new HomePackageReceiver(); private class HomePackageReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { rebuildUI(context); } }
不难发现,最终都调用了同一个方法rebuildUI(),肯定是这货无疑了。
private void rebuildUI(Context context) { if (!isAdded()) { Log.w(LOG_TAG, "Cannot build the DashboardSummary UI yet as the Fragment is not added"); return; } long start = System.currentTimeMillis(); final Resources res = getResources(); //添加之前先移除所有原来的View mDashboard.removeAllViews(); //通过SettingsActivity中的getDashboardCategories方法获取所有配置项 List<DashboardCategory> categories = ((SettingsActivity) context).getDashboardCategories(true); final int count = categories.size(); Log.i(LOG_TAG, "new categories=" + count); //遍历配置项列表集合,逐个添加(这块可结合上面的分析图看比较容易理解一些) for (int n = 0; n < count; n++) { DashboardCategory category = categories.get(n); //categoryView整个大类,例如无线和网络、设备 View categoryView = mLayoutInflater.inflate(R.layout.dashboard_category, mDashboard, false); //大类的标题文字,无线和网络 TextView categoryLabel = (TextView) categoryView.findViewById(R.id.category_title); categoryLabel.setText(category.getTitle(res)); ViewGroup categoryContent = (ViewGroup) categoryView.findViewById(R.id.category_content); final int tilesCount = category.getTilesCount(); //大类中添加对应的小类,例如 WLAN、蓝牙、SIM卡 for (int i = 0; i < tilesCount; i++) { DashboardTile tile = category.getTile(i); DashboardTileView tileView = new DashboardTileView(context); updateTileView(context, res, tile, tileView.getImageView(), tileView.getTitleTextView(), tileView.getStatusTextView()); tileView.setTile(tile); categoryContent.addView(tileView); } // Add the category mDashboard.addView(categoryView); } long delta = System.currentTimeMillis() - start; Log.d(LOG_TAG, "rebuildUI took: " + delta + " ms"); }
我们再回到SettingsActivity中的getDashboardCategories方法
public List<DashboardCategory> getDashboardCategories(boolean forceRefresh) { if (forceRefresh || mCategories.size() == 0) { buildDashboardCategories(mCategories); } return mCategories; }
实际调用buildDashboardCategories()方法,再来
/** * Called when the activity needs its list of categories/tiles built. * * @param categories The list in which to place the tiles categories. */ private void buildDashboardCategories(List<DashboardCategory> categories) { categories.clear(); //通过解析dashboard_categories.xml文件,添加到categories中 loadCategoriesFromResource(R.xml.dashboard_categories, categories, this); updateTilesList(categories); }
dashboard_categories.xml文件内容如下
源码位置 packages\apps\Settings\res\xml\dashboard_categories.xml
<dashboard-categories xmlns:android="http://schemas.android.com/apk/res/android"> <!-- WIRELESS and NETWORKS --> <dashboard-category android:id="@+id/wireless_section" android:key="@string/category_key_wireless" android:title="@string/header_category_wireless_networks" > <!-- Wifi --> <dashboard-tile android:id="@+id/wifi_settings" android:title="@string/wifi_settings_title" android:fragment="com.android.settings.wifi.WifiSettings" android:icon="@drawable/ic_settings_wireless" /> <!-- Bluetooth --> <dashboard-tile android:id="@+id/bluetooth_settings" android:title="@string/bluetooth_settings_title" android:fragment="com.android.settings.bluetooth.BluetoothSettings" android:icon="@drawable/ic_settings_bluetooth" /> ..... </dashboard-category> <!-- DEVICE --> <dashboard-category android:id="@+id/device_section" android:key="@string/category_key_device" android:title="@string/header_category_device" > <!-- Home --> <dashboard-tile android:id="@+id/home_settings" android:title="@string/home_settings" android:fragment="com.android.settings.HomeSettings" android:icon="@drawable/ic_settings_home" /> .... </dashboard-category> ...
看完这个xml文件是不是有一种恍然大明白的感觉,那就对了,这就对应了Setting的主界面,看到注释Wifi和Bluetooth等,注意观察上面的xml,dashboard-category节点为一个大类,dashboard-tile节点为里面的一个小类,对应的属性id不用多说,title即显示的标题文字,fragment对应点击时跳转的页面,icon为标题文字左边对应的图标。
回到文章开头的需求,如果只是简单的增加项或者删除项,只需在dashboard_categories.xml中增加对应的节点或者删除对应的节点,然后你就可以编译查看效果,舒舒服服的下班了。
⑧特以后你可就不舒服了,于是我灵机一动,既然系统原来是通过解析dashboard_categories.xml所有配置项,每次在onResume()中重新addView(),那么我们可以在这里做手脚(快,夸我机智),在解析系统dashboard_categories.xml得到的List添加我们想添加的配置项。
我们可以仿照谷歌工程师的做法,同样解析客户提供的xml来动态增加配置项。既然是解析,就得固定模板,肯定是我们需要给客户提供xml模板,他们来修改就好啦。
接下来,我们来看下系统是如何解析dashboard_categories.xml的,回到loadCategoriesFromResource()
public static void loadCategoriesFromResource(int resid, List<DashboardCategory> target, Context context) { XmlResourceParser parser = null; try { parser = context.getResources().getXml(resid); ... while ((type=parser.next()) != XmlPullParser.END_DOCUMENT && (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) { if (type == XmlPullParser.END_TAG || type == XmlPullParser.TEXT) { continue; } nodeName = parser.getName(); //大类的配置节点 if ("dashboard-category".equals(nodeName)) { //大类对应的bean DashboardCategory category = new DashboardCategory(); TypedArray sa = context.obtainStyledAttributes( attrs, com.android.internal.R.styleable.PreferenceHeader); //id赋值 category.id = sa.getResourceId( com.android.internal.R.styleable.PreferenceHeader_id, (int)DashboardCategory.CAT_ID_UNDEFINED); TypedValue tv = sa.peekValue( com.android.internal.R.styleable.PreferenceHeader_title); if (tv != null && tv.type == TypedValue.TYPE_STRING) { if (tv.resourceId != 0) { category.titleRes = tv.resourceId; } else { //title赋值 category.title = tv.string; } } sa.recycle(); sa = context.obtainStyledAttributes(attrs, com.android.internal.R.styleable.Preference); tv = sa.peekValue( com.android.internal.R.styleable.Preference_key); if (tv != null && tv.type == TypedValue.TYPE_STRING) { if (tv.resourceId != 0) { category.key = context.getString(tv.resourceId); } else { category.key = tv.string.toString(); } } sa.recycle(); final int innerDepth = parser.getDepth(); while ((type=parser.next()) != XmlPullParser.END_DOCUMENT && (type != XmlPullParser.END_TAG || parser.getDepth() > innerDepth)) { if (type == XmlPullParser.END_TAG || type == XmlPullParser.TEXT) { continue; } String innerNodeName = parser.getName(); //大类中对应的小类 if (innerNodeName.equals("dashboard-tile")) { //小类对应的bean DashboardTile tile = new DashboardTile(); sa = context.obtainStyledAttributes( attrs, com.android.internal.R.styleable.PreferenceHeader); tile.id = sa.getResourceId( com.android.internal.R.styleable.PreferenceHeader_id, (int)TILE_ID_UNDEFINED); tv = sa.peekValue( com.android.internal.R.styleable.PreferenceHeader_title); if (tv != null && tv.type == TypedValue.TYPE_STRING) { if (tv.resourceId != 0) { tile.titleRes = tv.resourceId; } else { //小类的title tile.title = tv.string; } } tv = sa.peekValue( com.android.internal.R.styleable.PreferenceHeader_summary); if (tv != null && tv.type == TypedValue.TYPE_STRING) { if (tv.resourceId != 0) { tile.summaryRes = tv.resourceId; } else { tile.summary = tv.string; } } //小类的icon tile.iconRes = sa.getResourceId( com.android.internal.R.styleable.PreferenceHeader_icon, 0); //小类的fragment tile.fragment = sa.getString( com.android.internal.R.styleable.PreferenceHeader_fragment); sa.recycle(); ... // Show the SIM Cards setting if there are more than 2 SIMs installed. if(tile.id != R.id.sim_settings || Utils.showSimCardTile(context)){ category.addTile(tile); ... } target.add(category); } else { XmlUtils.skipCurrentTag(parser); } } ... }
从系统的解析方法中,我提取了重要的有用的节点进而简化了解析方法,代码如下(在DashboardSummary.java中新增)
XmlPullParser xmlPullParser; XmlPullParserFactory xmlPullParserFactory; FileInputStream fileInputStream; private void loadCategoriesFromXml(List<DashboardCategory> categories){ String xmlPath = Environment.getExternalStorageDirectory().getAbsolutePath() +"/Android/dashboard.xml"; File file = new File(xmlPath); if (file.exists() && file.canRead()){ try { xmlPullParserFactory = XmlPullParserFactory.newInstance(); xmlPullParserFactory.setNamespaceAware(true); xmlPullParser = xmlPullParserFactory.newPullParser(); fileInputStream = new FileInputStream(xmlPath); xmlPullParser.setInput(fileInputStream, "utf-8"); int mEventType = xmlPullParser.getEventType(); DashboardCategory category = null; DashboardTile tile = null; while (mEventType != XmlPullParser.END_DOCUMENT){ switch (mEventType) { case XmlPullParser.START_DOCUMENT: break; case XmlPullParser.START_TAG: String name = xmlPullParser.getName(); if (name.equals("dashboard-category")){ category = new DashboardCategory(); category.title = xmlPullParser.getAttributeValue(null, "title"); }else if (name.equals("dashboard-tile")){ tile = new DashboardTile(); }else if (name.equals("title")){ tile.title = xmlPullParser.nextText(); }else if (name.equals("action")){ tile.intent = new Intent(xmlPullParser.nextText()); } break; case XmlPullParser.END_TAG: String nameP = xmlPullParser.getName(); if (nameP.equals("dashboard-tile")){ tile.iconRes = R.drawable.ic_settings_meituan; category.addTile(tile); }else if (nameP.equals("dashboard-category")){ categories.add(category); } break; } mEventType = xmlPullParser.next(); } } catch (Exception e) { throw new RuntimeException("Error parsing categories", e); }finally{ if (fileInputStream != null){ try{ fileInputStream.close(); xmlPullParserFactory = null; xmlPullParser = null; }catch(Exception e){ } } } }else { Log.i(LOG_TAG, ".dashboard.xml don't exists"); } }
对应的xml模板如下,到时候需要将dashboard.xml文件放置在SD卡的Android目录下
<?xml version="1.0" encoding="utf-8"?> <dashboard-categories> <dashboard-category title="title one"> <dashboard-tile> <title>红</title> <action>com.android.settings.SCHEDULE_POWER_ON_OFF_SETTING</action> </dashboard-tile> <dashboard-tile> <title>黄</title> <action>android.settings.ZEN_MODE_PRIORITY_SETTINGS</action> </dashboard-tile> <dashboard-tile> <title>蓝</title> <action>android.settings.DEVICE_INFO_SETTINGS</action> </dashboard-tile> </dashboard-category> <dashboard-category title="title two"> <dashboard-tile> <title>哈哈哈</title> <action>com.android1.settings.SCHEDULE_POWER_ON_OFF_SETTING</action> </dashboard-tile> </dashboard-category> </dashboard-categories>
好了,还差最后一步,在rebuildUI方法中增加我们自己的xml解析方法调用。
private void rebuildUI(Context context) { ... mDashboard.removeAllViews(); List<DashboardCategory> categories = ((SettingsActivity) context).getDashboardCategories(true); / List<DashboardCategory> myCategories = new ArrayList<DashboardCategory>(); myCategories.clear(); loadCategoriesFromXml(myCategories); for (int i = 0; i < myCategories.size(); i++) { categories.add(i, myCategories.get(i)); } /// final int count = categories.size(); Log.i(LOG_TAG, "new categories=" + count); for (int n = 0; n < count; n++) { ... } }
干的漂亮,这样就实现了文章开头的效果。