天天看點

Android設定之Preference源碼實作

本篇分析的庫是基于:androidx.preference.preference:[email protected]

 先來看張微信中的頁面,這個頁面實作其來比較簡單,實作的方式也有很多,但按可擴張性和簡單程度來說,個人認為還是要數Preference了,基本就是xml中配置了。

Android設定之Preference源碼實作

android中有提供給我們專門用作設定處理的庫Preference(支援的控件可直接在該庫下檢視),對于怎麼使用,android studio有提供模闆Settings Activity,接下來就看下它的實作。

先來簡單看下Preference定義的xml:

<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
    <PreferenceCategory app:title="@string/sync_header">
        <SwitchPreferenceCompat
            app:key="sync"
            app:title="@string/sync_title" />
        <SwitchPreferenceCompat
            app:dependency="sync"
            app:key="attachment"
            app:switchTextOn="kai"
            app:switchTextOff="guan"
            app:summaryOff="@string/attachment_summary_off"
            app:summaryOn="@string/attachment_summary_on"
            app:title="@string/attachment_title" />
    </PreferenceCategory>
</PreferenceScreen>
           

在來看下在Fragment中的使用:

public static class SettingsFragment extends PreferenceFragmentCompat {
    @Override
    public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
        setPreferencesFromResource(R.xml.root_preferences, rootKey);
    }
}
           

這裡就是調用了setPreferencesFromResource()把xml設定進去,接着就是使用這個Fragment就可以了。

咋一看,這裡并沒有view啊,那是設定頁面是怎麼顯示的呢,帶着這個疑問,一起來看下PreferenceFragmentCompat的源碼,Fragment建立view是在onCreateView這個方法中:

private int mLayoutResId = R.layout.preference_list_fragment;
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
        @Nullable Bundle savedInstanceState) {
​
    TypedArray a = getContext().obtainStyledAttributes(null,
            R.styleable.PreferenceFragmentCompat,
            R.attr.preferenceFragmentCompatStyle,
            0);
​
    mLayoutResId = a.getResourceId(R.styleable.PreferenceFragmentCompat_android_layout,
            mLayoutResId);
​
    ... ...
​
    a.recycle();
​
    final LayoutInflater themedInflater = inflater.cloneInContext(getContext());
​
    final View view = themedInflater.inflate(mLayoutResId, container, false);
​
    final View rawListContainer = view.findViewById(AndroidResources.ANDROID_R_LIST_CONTAINER);
    if (!(rawListContainer instanceof ViewGroup)) {
        throw new IllegalStateException("Content has view with id attribute "
                + "'android.R.id.list_container' that is not a ViewGroup class");
    }
​
    final ViewGroup listContainer = (ViewGroup) rawListContainer;
​
    final RecyclerView listView = onCreateRecyclerView(themedInflater, listContainer,
            savedInstanceState);
    if (listView == null) {
        throw new RuntimeException("Could not create RecyclerView");
    }
​
    mList = listView;
​
    listView.addItemDecoration(mDividerDecoration);
    setDivider(divider);
    if (dividerHeight != -1) {
        setDividerHeight(dividerHeight);
    }
    mDividerDecoration.setAllowDividerAfterLastItem(allowDividerAfterLastItem);
​
    // If mList isn't present in the view hierarchy, add it. mList is automatically inflated
    // on an Auto device so don't need to add it.
    if (mList.getParent() == null) {
        listContainer.addView(mList);
    }
    mHandler.post(mRequestFocus);
​
    return view;
}
           

這裡的style/declare-styleable/attr以及後面用到的都是定義在該庫的res/values/values.xml中。可以配合着來看,在values.xml中并沒有定義layout,是以使用的是預設的R.layout.preference_list_fragment,這是系統定義的資源檔案,

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    tools:ignore="NewApi"
    android:orientation="vertical"
    android:layout_height="match_parent"
    android:layout_width="match_parent" >
​
    <FrameLayout
        android:id="@android:id/list_container"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />
​
    <TextView android:id="@android:id/empty"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:padding="8dp"
        android:gravity="center"
        android:visibility="gone" />
​
</LinearLayout>
           

配合着對應的xml看就簡單多了,這裡用到的就是一個id為list_container的FrameLayout,接着就是建立一個RecyclerView加入到這個FrameLayout中去,這裡再來看下RecyclerView的建立:

public RecyclerView onCreateRecyclerView(LayoutInflater inflater, ViewGroup parent,
        Bundle savedInstanceState) {
    // If device detected is Auto, use Auto's custom layout that contains a custom ViewGroup
    // wrapping a RecyclerView
    if (getContext().getPackageManager().hasSystemFeature(PackageManager
            .FEATURE_AUTOMOTIVE)) {
        RecyclerView recyclerView = parent.findViewById(R.id.recycler_view);
        if (recyclerView != null) {
            return recyclerView;
        }
    }
    // 通常是執行這裡
    RecyclerView recyclerView = (RecyclerView) inflater
            .inflate(R.layout.preference_recyclerview, parent, false);
​
    recyclerView.setLayoutManager(onCreateLayoutManager());
    recyclerView.setAccessibilityDelegateCompat(
            new PreferenceRecyclerViewAccessibilityDelegate(recyclerView));
​
    return recyclerView;
}
           

對應的xml是:

<androidx.recyclerview.widget.RecyclerView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/recycler_view"
    style="?attr/preferenceFragmentListStyle"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingTop="0dp"
    android:paddingBottom="0dp"
    android:clipToPadding="false"/>
           

都比較簡單,總結就是建立了一個RecyclerView加入到了FrameLayout中,有了RecyclerView,顯示自然需要用到adapter了,設定adapter如下:

void bindPreferences() {
    final PreferenceScreen preferenceScreen = getPreferenceScreen();
    if (preferenceScreen != null) {
        getListView().setAdapter(onCreateAdapter(preferenceScreen));
        preferenceScreen.onAttached();
    }
    onBindPreferences();
}
           

建立adapter傳入的是PreferenceScreen對象,這個對象是怎麼來的呢?回到一開始的setPreferencesFromResource()方法:

public void setPreferencesFromResource(@XmlRes int preferencesResId, @Nullable String key) {
    requirePreferenceManager();
​
    final PreferenceScreen xmlRoot = mPreferenceManager.inflateFromResource(getContext(),
            preferencesResId, null);
​
    final Preference root;
    // key預設為null
    if (key != null) {
        root = xmlRoot.findPreference(key);
        if (!(root instanceof PreferenceScreen)) {
            throw new IllegalArgumentException("Preference object with key " + key
                    + " is not a PreferenceScreen");
        }
    } else {
        root = xmlRoot;
    }
​
    setPreferenceScreen((PreferenceScreen) root);
}
           

建立adapter傳入的PreferenceScreen就是在這裡建立的了,preferencesResId就是我們一開始傳入的xml檔案,這裡建立PreferenceScreen和建立view很像,這裡就不跟進去看了,無非就是拿到xml中配置的資訊通過反射建立對象,接着回到adapter建立:

protected RecyclerView.Adapter onCreateAdapter(PreferenceScreen preferenceScreen) {
    return new PreferenceGroupAdapter(preferenceScreen);
}
           

PreferenceGroupAdapter實作了RecyclerView.Adapter,那就來看下它的onCreateViewHolder方法:

public PreferenceViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
    ... ...
​
    final View view = inflater.inflate(descriptor.mLayoutResId, parent, false);
    if (view.getBackground() == null) {
        ViewCompat.setBackground(view, background);
    }
​
    final ViewGroup widgetFrame = view.findViewById(android.R.id.widget_frame);
    if (widgetFrame != null) {
        if (descriptor.mWidgetLayoutResId != 0) {
            inflater.inflate(descriptor.mWidgetLayoutResId, widgetFrame);
        } else {
            widgetFrame.setVisibility(View.GONE);
        }
    }
​
    return new PreferenceViewHolder(view);
}
           

這裡的descriptor對象封裝的是preference相關的布局檔案,到這裡會有一個疑惑,我們在xml中明明沒有配置layout,那這裡建立view的layout是從哪裡來的呢?那就得先來看看Preference這個類的構造函數了:

public Preference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
    final TypedArray a = context.obtainStyledAttributes(
            attrs, R.styleable.Preference, defStyleAttr, defStyleRes);
    ... ...
​
    mLayoutResId = TypedArrayUtils.getResourceId(a, R.styleable.Preference_layout,
            R.styleable.Preference_android_layout, R.layout.preference);
​
    mWidgetLayoutResId = TypedArrayUtils.getResourceId(a, R.styleable.Preference_widgetLayout,
            R.styleable.Preference_android_widgetLayout, 0);
​
    ... ...
​
    a.recycle();
}
           

這裡就是擷取layoutId,如果我們的xml中沒有配置,那麼就使用預設的,mWidgetLayoutResId是設定項裡面的控制按鈕,那這裡的id是如何根據不同的preference來确定不同的id呢?這裡以SwitchPreferenceCompat為例:

public SwitchPreferenceCompat(Context context, AttributeSet attrs) {
    this(context, attrs, R.attr.switchPreferenceCompatStyle);
}
           

這裡指定了主題中使用的值為switchPreferenceCompatStyle,再來看下主題定義:

<style name="PreferenceThemeOverlay">
    ... ...
    <item name="switchPreferenceCompatStyle">@style/Preference.SwitchPreferenceCompat.Material</item>
    ... ...
</style>
<style name="Preference.SwitchPreferenceCompat.Material">
    <item name="android:layout">@layout/preference_material</item>
    <item name="allowDividerAbove">false</item>
    <item name="allowDividerBelow">true</item>
    <item name="iconSpaceReserved">@bool/config_materialPreferenceIconSpaceReserved</item>
</style>
<style name="Preference.SwitchPreferenceCompat">
    <item name="android:widgetLayout">@layout/preference_widget_switch_compat</item>
    <item name="android:switchTextOn">@string/v7_preference_on</item>
    <item name="android:switchTextOff">@string/v7_preference_off</item>
</style>
<style name="Preference">
    <item name="android:layout">@layout/preference</item>
</style>
           

可以看到,SwitchPreferenceCompat預設使用的是preference_widget_switch_compat.xml,這裡就隻是定義了一個SwitchCompat控制,這裡來看下主布局mLayoutResId(preference.xml):

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:minHeight="?android:attr/listPreferredItemHeight"
    android:gravity="center_vertical"
    android:paddingEnd="?android:attr/scrollbarSize"
    android:paddingRight="?android:attr/scrollbarSize"
    android:background="?android:attr/selectableItemBackground">
​
    <FrameLayout
        android:id="@+id/icon_frame"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content">
        <androidx.preference.internal.PreferenceImageView
            android:id="@android:id/icon"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:maxWidth="48dp"
            app:maxHeight="48dp" />
    </FrameLayout>
​
    <RelativeLayout
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="15dip"
        android:layout_marginLeft="15dip"
        android:layout_marginEnd="6dip"
        android:layout_marginRight="6dip"
        android:layout_marginTop="6dip"
        android:layout_marginBottom="6dip"
        android:layout_weight="1">
​
        <TextView android:id="@android:id/title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:singleLine="true"
            android:textAppearance="?android:attr/textAppearanceLarge"
            android:textColor="?android:attr/textColorPrimary"
            android:ellipsize="marquee"
            android:fadingEdge="horizontal" />
​
        <TextView android:id="@android:id/summary"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_below="@android:id/title"
            android:layout_alignStart="@android:id/title"
            android:layout_alignLeft="@android:id/title"
            android:textAppearance="?android:attr/textAppearanceSmall"
            android:textColor="?android:attr/textColorSecondary"
            android:maxLines="4" />
​
    </RelativeLayout>
​
    <!-- Preference should place its actual preference widget here. -->
    <LinearLayout android:id="@android:id/widget_frame"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:gravity="center_vertical"
        android:orientation="vertical" />
​
</LinearLayout> 
           

到這,布局來源的事就ok了,接着回到PreferenceGroupAdapter.onCreateViewHolder(),這裡主要就是填充出上面這個布局,如果有widgetLayout的話,在加到上面id為widget_frame的LinearLayout布局中去,執行完後就看看onBindViewHolder()了:

public void onBindViewHolder(@NonNull PreferenceViewHolder holder, int position) {
    final Preference preference = getItem(position);
    preference.onBindViewHolder(holder);
}
           

view的處理又回到具體的preference了,至此,頁面的顯示的邏輯就差不多了。熟悉了這整個流程,想要定制自己的設定界面,那就比較簡單了。比如原生的SwitchPreferenceCompat樣式太醜,想要修改switch的樣式,那就可以在xml布局中定義widgetLayout,如果想要整體替換,那就定義layout屬性了,這裡要注意一點,如果要使用xml中配置的title等屬性,View使用的id就要和preference.xml中一樣,不然就需要繼承Preference自己去處理了。