关于设置这块儿大概分为两部分来说,一是视图UI,二是数据处理。第一篇所说的UI只是整体的一个概述,而setting的UI部分相对复杂,因此这里单独来说一下。首先看一下总的布局文件setting_container.xml:
MaxLinearLayout重写了LinearLayout,主要是对传统的LinearLayout进行一个最大宽高的限制。内层是我们常见的TabHost+ViewPager的一个页面切换效果。UI显示如下:
代码怎样处理呢?回到我们说过的SettingManager:
private void initializeSettings() {
if (mSettingLayout == null && mSettingController.getPreferenceGroup() != null) {
mSettingLayout = (ViewGroup) getContext().inflate(R.layout.setting_container,
SETTING_PAGE_LAYER);
mTabHost = (TabHost) mSettingLayout.findViewById(R.id.tab_title);
mTabHost.setup();
// For tablet
int settingKeys[] = SettingConstants.SETTING_GROUP_COMMON_FOR_TAB;
if (FeatureSwitcher.isSubSettingEnabled()) {
settingKeys = SettingConstants.SETTING_GROUP_MAIN_COMMON_FOR_TAB;
} else if (FeatureSwitcher.isLomoEffectEnabled() && getContext().isNonePickIntent()) {
settingKeys = SettingConstants.SETTING_GROUP_COMMON_FOR_LOMOEFFECT;
}
List list = new ArrayList();
if (getContext().isNonePickIntent() || getContext().isStereoMode()) {
...
list.add(new Holder(TAB_INDICATOR_KEY_COMMON, R.drawable.ic_tab_common_setting,
settingKeys));
list.add(new Holder(TAB_INDICATOR_KEY_CAMERA, R.drawable.ic_tab_camera_setting,
SettingConstants.SETTING_GROUP_CAMERA_FOR_TAB));
list.add(new Holder(TAB_INDICATOR_KEY_VIDEO, R.drawable.ic_tab_video_setting,
SettingConstants.SETTING_GROUP_VIDEO_FOR_TAB));
...
}
...
int size = list.size();
List pageViews = new ArrayList();
for (int i = 0; i < size; i++) {
Holder holder = list.get(i);
// new page view
SettingListLayout pageView = (SettingListLayout) getContext().inflate(
R.layout.setting_list_layout, SETTING_PAGE_LAYER);
ArrayList listItems = new ArrayList();
pageView.initialize(getListPreferences(holder.mSettingKeys, i == 0));
//这里将ArrayList对象传递到了每一个Item,即SettingListLayout,而这个holder.mSettingKeys是一个int数组,每一个值表示一种状态
pageViews.add(pageView);
// new indicator view
ImageView indicatorView = new ImageView(getContext());
if (indicatorView != null) {
indicatorView.setBackgroundResource(R.drawable.bg_tab_title);
indicatorView.setImageResource(holder.mIndicatorIconRes);
indicatorView.setScaleType(ScaleType.CENTER);
}
mTabHost.addTab(mTabHost.newTabSpec(holder.mIndicatorKey)
.setIndicator(indicatorView).setContent(android.R.id.tabcontent));
}
mAdapter = new MyPagerAdapter(pageViews);
mPager = (ViewPager) mSettingLayout.findViewById(R.id.pager);
mPager.setAdapter(mAdapter);
mPager.setOnPageChangeListener(mAdapter);
mTabHost.setOnTabChangedListener(this);
}
Util.setOrientation(mSettingLayout, getOrientation(), false);
}
在这个UI初始化的过程中有两个需要关注的对象:一是SettingListLayout(即每一页PageView,共三页),二是ListPreference(用于保存每一个item条目上的可选内容)。
我们看到每一页PageView中的ListView都有多种item的布局,而这也是我们在做开发中常常会遇到的问题,来看一下Camera是怎样处理的:
private int getSettingLayoutId(ListPreference pref) {
// If the preference is null, it will be the only item , i.e.
// 'Restore setting' in the popup window.
//将所有item分成以下五种布局类型即可载入五种不同的item layout,注意这里有五个return语句
if (pref == null) {
return R.layout.in_line_setting_restore;
}
// Currently, the RecordLocationPreference is the only setting
// which applies the on/off switch.
if (isSwitchSettingItem(pref)) {
return R.layout.in_line_setting_switch;
} else if (isVirtualSettingItem(pref)) {
return R.layout.in_line_setting_virtual;
} else if (isSwitchVirtualItem(pref)) {
return R.layout.in_line_setting_switch_virtual;
}
return R.layout.in_line_setting_sublist;
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
ListPreference pref = mListItem.get(position);
//五个Layout布局文件对应了五种自定义viewgroup
if (convertView != null) {
if (pref == null) {
if (!(convertView instanceof InLineSettingRestore)) {
convertView = null;
}
} else if (isSwitchSettingItem(pref)) {
if (!(convertView instanceof InLineSettingSwitch)) {
convertView = null;
}
} else if (isVirtualSettingItem(pref)) {
if (!(convertView instanceof InLineSettingVirtual)) {
convertView = null;
}
} else if (isSwitchVirtualItem(pref)) {
if (!(convertView instanceof InLineSettingSwitchVirtual)) {
convertView = null;
}
} else {
if (!(convertView instanceof InLineSettingSublist)) {
convertView = null;
}
}
if (convertView != null) {
((InLineSettingItem) convertView).initialize(pref);
SettingUtils.setEnabledState(convertView,
(pref == null ? true : pref.isEnabled()));
return convertView;
}
}
//在这里并没有直接进行数据填充,而是交给下一层的自定义viewgroup
int viewLayoutId = getSettingLayoutId(pref);
InLineSettingItem view = (InLineSettingItem) mInflater.inflate(viewLayoutId, parent,
false);
if (viewLayoutId == R.layout.in_line_setting_restore) {
view.setId(R.id.restore_default);
}
view.initialize(pref); // no init for restore one
view.setSettingChangedListener(SettingListLayout.this);
SettingUtils.setEnabledState(convertView, (pref == null ? true : pref.isEnabled()));
return view;
}
//可以使用下面的方法判断是否是一个带有switch开关的item,其它同理
private boolean isSwitchSettingItem(ListPreference pref) {
return SettingConstants.KEY_RECORD_LOCATION.equals(pref.getKey())
|| SettingConstants.KEY_VIDEO_RECORD_AUDIO.equals(pref.getKey())
|| SettingConstants.KEY_VIDEO_EIS.equals(pref.getKey())
|| SettingConstants.KEY_VIDEO_3DNR.equals(pref.getKey())
|| SettingConstants.KEY_CAMERA_ZSD.equals(pref.getKey())
|| SettingConstants.KEY_VOICE.equals(pref.getKey())
|| SettingConstants.KEY_CAMERA_FACE_DETECT.equals(pref.getKey())
|| SettingConstants.KEY_HDR.equals(pref.getKey())
|| SettingConstants.KEY_GESTURE_SHOT.equals(pref.getKey())
|| SettingConstants.KEY_SMILE_SHOT.equals(pref.getKey())
|| SettingConstants.KEY_SLOW_MOTION.equals(pref.getKey())
|| SettingConstants.KEY_CAMERA_AIS.equals(pref.getKey())
|| SettingConstants.KEY_ASD.equals(pref.getKey())
|| SettingConstants.KEY_DNG.equals(pref.getKey());
}
根据ListPreference获取对应的Key,然后将固定的key值对应到固定的layout id上,我们以上图中的人脸美化这个条目为例进一步深入,所以接下来进入的是InLineSettingSublist:
@Override
protected void onFinishInflate() {
super.onFinishInflate();
mEntry = (TextView) findViewById(R.id.current_setting);
mImage = (ImageView) findViewById(R.id.image);
//PageView内部的ListView显示的当前item layout
setOnClickListener(mOnClickListener);
}
protected OnClickListener mOnClickListener = new OnClickListener() {
@Override
public void onClick(View view) {
Log.d(TAG, "onClick() mShowingChildList=" + mShowingChildList + ", mPreference="
+ mPreference);
if (!mShowingChildList && mPreference != null && mPreference.isClickable()) {
expendChild();
} else {
collapseChild();
}
}
};
public boolean expendChild() {
boolean expend = false;
if (!mShowingChildList) {
mShowingChildList = true;
if (mListener != null) {
mListener.onShow(this);
}
//载入下一个子视图(ListView)
mSettingLayout = (SettingSublistLayout) mContext.inflate(
R.layout.setting_sublist_layout, ViewManager.VIEW_LAYER_SETTING);
mSettingContainer = mSettingLayout.findViewById(R.id.container);
//还是那个ListPreference
mSettingLayout.initialize(mPreference);
mContext.addView(mSettingLayout, ViewManager.VIEW_LAYER_SETTING);
mContext.addOnOrientationListener(this);
mSettingLayout.setSettingChangedListener(this);
setOrientation(mContext.getOrientationCompensation(), false);
fadeIn(mSettingLayout);
highlight();
expend = true;
}
Log.d(TAG, "expendChild() return " + expend);
return expend;
}
来看这个子ListView的布局处理:
@Override
public View getView(int position, View convertView, ViewGroup parent) {
ViewHolder holder = null;
if (convertView == null) {
//该子listview的item上有一个ImageView、一个TextView和一个RadioButton
convertView = mInflater.inflate(R.layout.setting_sublist_item, null);
holder = new ViewHolder();
holder.mImageView = (ImageView) convertView.findViewById(R.id.image);
holder.mTextView = (TextView) convertView.findViewById(R.id.title);
holder.mRadioButton = (RadioButton) convertView.findViewById(R.id.radio);
convertView.setTag(holder);
} else {
holder = (ViewHolder) convertView.getTag();
}
int iconId = mPreference.getIconId(position);
//像我们当前的人脸美化所用的listview是没有icon,所以这里设为GONE即可
if (mPreference.getIconId(position) == ListPreference.UNKNOWN) {
holder.mImageView.setVisibility(View.GONE);
} else {
holder.mImageView.setVisibility(View.VISIBLE);
holder.mImageView.setImageResource(iconId);
}
holder.mTextView.setText(mPreference.getEntries()[position]);
holder.mRadioButton.setChecked(position == mSelectedIndex);
// SettingUtils.setEnabledState(convertView,
// mPreference.isEnabled(position));
return convertView;
}
那么事件如何层层传递到Activity呢?看下各层视图类的定义:
public class SettingSublistLayout extends RotateLayout implements AdapterView.OnItemClickListener {...}
public class InLineSettingSublist extends InLineSettingItem implements
SettingSublistLayout.Listener, CameraActivity.OnOrientationListener {...}
public abstract class InLineSettingItem extends RelativeLayout {...}
public class SettingListLayout extends FrameLayout implements InLineSettingItem.Listener,
AdapterView.OnItemClickListener, OnScrollListener {...}
public class SettingManager extends ViewManager implements View.OnClickListener,
SettingListLayout.Listener, CameraActivity.OnPreferenceReadyListener, OnTabChangeListener {...}
基本上是一个通过接口层层向外传递的过程,其中InLineSettingItem作为一个抽象类,其定义的接口由其子类实现(子类同时将继承其接口对象),然后就进入了SettingListLayout,进而转发到SettingManager,最终到达CameraActivity。
我们来看一下这个抽象类的子类都有哪些:
这五个子类就是前面说的五个布局文件中的自定义viewgroup。
视图关系与事件逻辑就讲到这里,接下来看一下上面提到的ListPreference,回到SettingManager的以下代码:
int settingKeys[] = SettingConstants.SETTING_GROUP_COMMON_FOR_TAB;
...
pageView.initialize(getListPreferences(holder.mSettingKeys, i == 0));
这个mSettingKeys整形数组初始化为如下,每一个数都表示一条item(即一个Camera属性)
public static final int[] SETTING_GROUP_COMMON_FOR_TAB = new int[]{
ROW_SETTING_DUAL_CAMERA_MODE,
ROW_SETTING_RECORD_LOCATION,//common
ROW_SETTING_MULTI_FACE_MODE,
ROW_SETTING_EXPOSURE,//common
ROW_SETTING_COLOR_EFFECT,//common
ROW_SETTING_SCENCE_MODE,//common
ROW_SETTING_WHITE_BALANCE,//common
ROW_SETTING_IMAGE_PROPERTIES,
ROW_SETTING_ANTI_FLICKER,//common
};
除了上面的通用设置属性,还有针对camera和video的针对属性,同理定义的数组如下:
public static final int[] SETTING_GROUP_CAMERA_FOR_TAB = new int[]{
ROW_SETTING_ZSD,//camera
ROW_SETTING_AIS,//camera
ROW_SETTING_VOICE,//camera
ROW_SETTING_CAMERA_FACE_DETECT,//camera
ROW_SETTING_GESTURE_SHOT,
ROW_SETTING_SMILE_SHOT,//camera
ROW_SETTING_ASD,//camera
ROW_SETTING_DNG,
ROW_SETTING_SELF_TIMER,//camera
ROW_SETTING_CONTINUOUS_NUM,//camera
ROW_SETTING_PICTURE_SIZE,//camera
ROW_SETTING_PICTURE_RATIO,//camera
ROW_SETTING_ISO,//camera
ROW_SETTING_FACEBEAUTY_PROPERTIES,//camera:Cfb
};
public static final int[] SETTING_GROUP_VIDEO_FOR_TAB = new int[]{
ROW_SETTING_3DNR,
ROW_SETTING_VIDEO_STABLE,//video
ROW_SETTING_MICROPHONE,//video
ROW_SETTING_AUDIO_MODE,//video
ROW_SETTING_TIME_LAPSE,//video
ROW_SETTING_VIDEO_QUALITY,//video
ROW_SETTING_SLOW_MOTION_VIDEO_QUALITY,
};
只要Hal层做了相应的支持,上述数组中表示的所有属性调节都将出现在Camera的Setting中
然后使用该数组封装得到ListPreference对象:
private ArrayList getListPreferences(int[] keys, boolean addrestore) {
ArrayList listItems = new ArrayList();
for (int i = 0; i < keys.length; i++) {
//keys数组的值对应的是KEYS_FOR_SETTING这个数组的index下标,这里将得到类似pref_camera_id_key这样的值
String key = SettingConstants.getSettingKey(keys[i]);
/*
getListPreference(key)这个方法的实现在SettingCtrl中,然后将调用SettingGenerator的getListPreference(int row)方法,
传到SettingCtrl的是数组值,然后又转换为了数组下标
*/
ListPreference pref = mSettingController.getListPreference(key);
if (pref != null && pref.isShowInSetting()) {
if (SettingConstants.KEY_VIDEO_QUALITY.equals(key)) {
if (!("on".equals(mSettingController
.getSettingValue(SettingConstants.KEY_SLOW_MOTION)))) {
listItems.add(pref);
}
} else {
listItems.add(pref);
}
}
}
if (addrestore) {
listItems.add(null);
}
return listItems;
}
看一下SettingGenerator的getListPreference方法:
public ListPreference getListPreference(int row) {
int currentCameraId = mICameraDeviceManager.getCurrentCameraId();
//根据CameraId得到当前使用的ListPreference
ArrayList preferences = mPreferencesMap.get(currentCameraId);
if (preferences == null) {
Log.e(TAG, "Call setting before setting updated, return null");
return null;
}
return preferences.get(row);
}
mPreferencesMap定义如下:
private HashMap> mPreferencesMap;
//一般来说我们都有两个camera,所以我们需要定义两个ListPreference集合分别来表示前置后置两个摄像头的参数配置信息
int cameraCounts = mICameraDeviceManager.getNumberOfCameras();
mPreferencesMap = new HashMap>(cameraCounts);
到这里我们得到了当前摄像头所使用的ListPreference,那它与UI视图怎样建立联系呢?上面说过每一个item都对应了一个int值,而这个int值就分别对应了不同的ListPreference对象。
接下来我们看下ListPreference的创建过程,回到第二篇中讲的CameraDeviceCtrl,在Camera成功open之后,将执行如下方法:
private void initializeSettingController() {
Log.d(TAG, "[initializeSettingController]");
if (!mISettingCtrl.isSettingsInitialized()) {
//该xml文件定义了setting的所有条目
mISettingCtrl.initializeSettings(R.xml.camera_preferences, mPreferences.getGlobal(),
mPreferences.getLocal());
}
//mPreferences最初是在CameraActivity中new出来的ComboPreferences对象
mISettingCtrl.updateSetting(mPreferences.getLocal());
mMainHandler.sendEmptyMessage(MSG_CAMERA_PREFERENCE_READY);
}
而后进入SettingCtrl:
@Override
public void initializeSettings(int preferenceRes, SharedPreferences globalPref,
SharedPreferences localPref) {
Log.i(TAG, "[initializeSettings]...");
mLocalPrefs.put(mICameraDeviceManager.getCurrentCameraId(), localPref);
mPrefTransfer = new SharedPreferencesTransfer(globalPref, localPref);
mSettingGenerator = new SettingGenerator(mICameraContext, mPrefTransfer);
mSettingGenerator.createSettings(preferenceRes);
createRules();
mIsInitializedSettings = true;
}
最终进入SettingGenerator:
public void createSettings(int preferenceRes) {
mPreferenceRes = preferenceRes;
mInflater = new PreferenceInflater(mContext, mPrefTransfer);
int currentCameraId = mICameraDeviceManager.getCurrentCameraId();
//自定义了一个Inflater用来解析上面提到的xml文件(并且在解析过程中封装得到多个ListPreference对象),
//一个摄像头所对应的所有可用setting属性构成了一个PreferenceGroup
PreferenceGroup group = (PreferenceGroup) mInflater.inflate(preferenceRes);
mPreferencesGroupMap.put(currentCameraId, group);
createSettingItems();
createPreferences(group, currentCameraId);
}
/*
一个SettingItem记录了当前item条目的id信息、当前的value值、上次选择的value值以及
默认的value值,另外SettingItem内部持有当前item的ListPreference对象
*/
private void createSettingItems() {
int cameraCounts = mICameraDeviceManager.getNumberOfCameras();
for (int i = 0; i < cameraCounts; i++) {
ArrayList settingItems = new ArrayList();
for (int settingId = 0; settingId < SettingConstants.SETTING_COUNT; settingId++) {
SettingItem settingItem = new SettingItem(settingId);
String key = SettingConstants.getSettingKey(settingId);
int type = SettingConstants.getSettingType(settingId);
settingItem.setKey(key);
settingItem.setType(type);
settingItems.add(settingItem);
}
mSettingItemsMap.put(i, settingItems);
}
}
/*
根据CameraId和上面得到的group将ListPreference保存到HashMap
*/
private void createPreferences(PreferenceGroup group, int cameraId) {
Log.i(TAG, "[createPreferences], cameraId:" + cameraId + ", group:" + group);
ArrayList preferences = mPreferencesMap.get(cameraId);
mSupportedImageProperties = new ArrayList();
mSupportedFaceBeautyProperties = new ArrayList();
if (preferences == null) {
preferences = new ArrayList();
ArrayList settingItems = mSettingItemsMap.get(cameraId);
for (int settingId = 0; settingId < SettingConstants.SETTING_COUNT; settingId++) {
String key = SettingConstants.getSettingKey(settingId);
ListPreference preference = group.findPreference(key);
preferences.add(preference);
SettingItem settingItem = settingItems.get(settingId);
//上面说过SettingItem持有一个ListPreference的对象
settingItem.setListPreference(preference);
}
mPreferencesMap.put(cameraId, preferences);
}
// every camera maintain one setting item list.
//从ICameraDevice得到Parameters过滤掉当前Camera不支持的一些属性
filterPreferences(preferences, cameraId);
}
综上得到如下类图关系:
接着前面说过的,当事件通过接口层层传递到CameraActivity时,将通过ISettingCtrl的对象调整item条目的value值,再通过CameraDeviceCtrl将最新的Camera属性适用到设备。如下所求:
private SettingManager.SettingListener mSettingListener = new SettingManager.SettingListener() {
@Override
public void onSharedPreferenceChanged(ListPreference preference) {
Log.d(TAG, "[onSharedPreferenceChanged]");
if (!isCameraOpened()) {
return;
}
if (preference != null) {
String settingKey = preference.getKey();
String value = preference.getValue();
mISettingCtrl.onSettingChanged(settingKey, value);
}
mCameraDeviceCtrl.applyParameters(false);
}
...
}
而SettingCtrl将对item进行重新赋值(不操作UI),如下:
private void onSettingChanged(Parameters parameters, int currentCameraId, String key,
String value) {
int settingId = SettingConstants.getSettingId(key);
SettingItem setting = getSetting(settingId);
String lastValue = setting.getLastValue();
Log.i(TAG, "[onSettingChanged], key: " + key + ", value:" + value + ", lastValue:"
+ lastValue);
if (value == null || value.equals(lastValue) && ! SettingConstants.KEY_PICTURE_SIZE.equals(key)){
Log.w(TAG, "[onSettingChanged], do not need to change, return.");
return;
}
setting.setValue(value);
setting.setLastValue(value);
...
}
流程相对复杂但也相对清楚,但我们发现一个问题,说了这么多,item上的value值的变化即UI操作到底在哪里进行的?总得有一个setText()或者setImage()之类的方法才对吧,我们找到SettingSublistLayout的onItemClick事件:
@Override
public void onItemClick(AdapterView> parent, View view, int index, long id) {
Log.d(TAG,
"onItemClick(" + index + ", " + id + ") and oldIndex = "
+ mAdapter.getSelectedIndex());
boolean realChanged = index != mAdapter.getSelectedIndex();
if (realChanged) {
//这里最后将数据保存在SharedPreferences中
mPreference.setValueIndex(index);
}
if (mListener != null) {
//这里最终调用到SettingManager的onSettingChanged()方法
mListener.onSettingChanged(realChanged);
}
}
SettingManager.java
@Override
public void onSettingChanged(SettingListLayout settingList, ListPreference preference) {
Log.i(TAG, "onSettingChanged(" + settingList + ")");
if (mListener != null) {
mListener.onSharedPreferenceChanged(preference);
mPreference = preference;
}
refresh();
}
这个refresh()方法应该就是我们想要找的,但它本身没有,我们查找它的父类ViewManager:
public final void refresh() {
if (mShowing) {
onRefresh();
}
}
onRefresh()方法在该父类中没有实现,我们回到它的子类SettingManager:
@Override
public void onRefresh() {
Log.i(TAG, "onRefresh() isShowing()=" + isShowing() + ", mShowingContainer="
+ mShowingContainer);
if (mShowingContainer && mAdapter != null) { // just apply checker when
// showing settings
//通知UI进行刷新,这就是我们想要找的
mAdapter.notifyDataSetChanged();
}
}
进入内部类MyPagerAdapter:
@Override
public void notifyDataSetChanged() {
super.notifyDataSetChanged();
for (SettingListLayout page : mPageViews) {
if (page != null) {
page.setSettingChangedListener(SettingManager.this);
page.reloadPreference();
}
}
}
然后是SettingListLayout:
public void reloadPreference() {
int count = mSettingList.getChildCount();
for (int i = 0; i < count; i++) {
ListPreference pref = mListItem.get(i);
if (pref != null) {
InLineSettingItem settingItem = (InLineSettingItem) mSettingList.getChildAt(i);
settingItem.reloadPreference();
}
}
}
接着是InLineSettingItem,由于它是一个抽象类,而其子类InLineSettingVirtual(也可能是其它子类)同时重写了reloadPreference()方法,我们直接看它的子类方法:
@Override
public void reloadPreference() {
int len = mPreference.getEntries().length;
mChildPrefs = new ListPreference[len];
for (int i = 0; i < len; i++) {
String key = String.valueOf(mPreference.getEntries()[i]);
mChildPrefs[i] = mContext.getListPreference(key);
Log.d(TAG, "reloadPreference() mChildPrefs[" + i + "|" + key + "]=" + mChildPrefs[i]);
}
updateView();
}
@Override
protected void updateView() {
if (mPreference == null || mChildPrefs == null) {
return;
}
setOnClickListener(null);
int len = mChildPrefs.length;
boolean allDefault = true;
int enableCount = 0;
for (int i = 0; i < len; i++) {
ListPreference pref = mChildPrefs[i];
if (pref == null) {
continue;
}
String defaultValue = String.valueOf(mPreference.getEntryValues()[i]);
String value = pref.getOverrideValue();
if (value == null) {
value = pref.getValue();
}
if (pref.isEnabled()) {
enableCount++;
}
// we assume pref and default value is not null.
if (allDefault && !defaultValue.equals(value)) {
allDefault = false;
}
}
if (allDefault) {
mEntry.setText(mPreference.getDefaultValue());
} else {
mEntry.setText("");
}
boolean enabled = (enableCount == len);
mPreference.setEnabled(enabled);
setEnabled(mPreference.isEnabled());
setOnClickListener(mOnClickListener);
Log.d(TAG, "updateView() enableCount=" + enableCount + ", len=" + len);
}
至此,一个Camera Setting完整的UI操作过程就结束了。