};
/**
-
处理软键盘backSpace回退事件
-
@param editTxt 光标所在的文本输入框
*/
private void onBackspacePress(EditText editTxt) {
try {
int startSelection = editTxt.getSelectionStart();
// 只有在光标已经顶到文本输入框的最前方,在判定是否删除之前的图片,或两个View合并
if (startSelection == 0) {
int editIndex = layout.indexOfChild(editTxt);
// 如果editIndex-1<0,
View preView = layout.getChildAt(editIndex - 1);
if (null != preView) {
if (preView instanceof RelativeLayout) {
// 光标EditText的上一个view对应的是图片,删除图片操作
onImageCloseClick(preView);
} else if (preView instanceof EditText) {
// 光标EditText的上一个view对应的还是文本框EditText
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
} -
当EditText内容为空时,发现手机根本无法响应软键盘的删除监听,这个是为什么呢?
-
可以看一下源码,EditText继承自TextView,翻看TextView的代码,里面有一个叫做InputConnection的东西,看起是什么输入连接的意思。如果想实现删除的功能,需要自行实现重写一个deleteSurroundingText()方法。
/**
- 删除操作
- @param beforeLength beforeLength
- @param afterLength afterLength
- @return
*/
@Override
public boolean deleteSurroundingText(int beforeLength, int afterLength) {
HyperLogUtils.d(“DeletableEditText—deleteSurroundingText–”+beforeLength+“----”+afterLength);
if (beforeLength == 1 && afterLength == 0) {
return sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL))
&& sendKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL));
}
return super.deleteSurroundingText(beforeLength, afterLength);
}
05.在指定位置插入图片
- 当点击插入图片的时候,需要思考两个问题。第一个是在那个位置插入图片,所以需要定位到这个位置;第二个是插入图片后,什么时候折行操作。
- 对于上面两个问题,这个位置可以取光标所在的位置,但是对于一个EditText输入文本,插入图片这个位置可以分多种情况:
- 如果光标已经顶在了editText的最前面,则直接插入图片,并且EditText下移即可
- 如果光标已经顶在了editText的最末端,则需要添加新的imageView
- 如果光标已经顶在了editText的最中间,则需要分割字符串,分割成两个EditText,并在两个EditText中间插入图片
- 如果当前获取焦点的EditText为空,直接在EditText下方插入图片,并且插入空的EditText
/**
- 插入一张图片
- @param imagePath 图片路径地址
*/
public void insertImage(String imagePath) {
if (TextUtils.isEmpty(imagePath)){
return;
}
try {
//lastFocusEdit获取焦点的EditText
String lastEditStr = lastFocusEdit.getText().toString();
//获取光标所在位置
int cursorIndex = lastFocusEdit.getSelectionStart();
//获取光标前面的字符串
String editStr1 = lastEditStr.substring(0, cursorIndex).trim();
//获取光标后的字符串
String editStr2 = lastEditStr.substring(cursorIndex).trim();
//获取焦点的EditText所在位置
int lastEditIndex = layout.indexOfChild(lastFocusEdit);
if (lastEditStr.length() == 0) {
//如果当前获取焦点的EditText为空,直接在EditText下方插入图片,并且插入空的EditText
} else if (editStr1.length() == 0) {
//如果光标已经顶在了editText的最前面,则直接插入图片,并且EditText下移即可
} else if (editStr2.length() == 0) {
// 如果光标已经顶在了editText的最末端,则需要添加新的imageView和EditText
} else {
//如果光标已经顶在了editText的最中间,则需要分割字符串,分割成两个EditText,并在两个EditText中间插入图片
}
hideKeyBoard();
} catch (Exception e) {
e.printStackTrace();
}
}
06.在指定位置插入输入文字
- 前面已经提到了,如果一个富文本是:文字1+图片1+文字2+文字3+图片3+图片4,那么点击文字1控件则在此输入文字,点击文字3控件则在此输入文字。
- 所以,这样操作,确定处理记录当前的焦点区域位置十分重要。当前的编辑器已经添加了多个输入文本EditText,现在的问题在于需要记录当前编辑的EditText,在应用样式的时候定位到输入的控件,在编辑器中添加一个变量lastFocusEdit。具体可以看代码……
/**
- 所有EditText的焦点监听listener
*/
private OnFocusChangeListener focusListener;
focusListener = new OnFocusChangeListener() {
@Override
public void onFocusChange(View v, boolean hasFocus) {
if (hasFocus) {
lastFocusEdit = (EditText) v;
HyperLogUtils.d(“HyperTextEditor—onFocusChange–”+lastFocusEdit);
}
}
};
/**
- 在特定位置插入EditText
- @param index 位置
- @param editStr EditText显示的文字
*/
public void addEditTextAtIndex(final int index, CharSequence editStr) {
//省略部分代码
try {
EditText editText = createEditText(“插入文字”, EDIT_PADDING);
editText.setOnFocusChangeListener(focusListener);
layout.addView(editText, index);
//插入新的EditText之后,修改lastFocusEdit的指向
lastFocusEdit = editText;
//获取焦点
lastFocusEdit.requestFocus();
//将光标移至文字指定索引处
lastFocusEdit.setSelection(editStr.length(), editStr.length());
} catch (Exception e) {
e.printStackTrace();
}
}
07.如果对选中文字加粗
- Span 的分类介绍
- 字符外观,这种类型修改字符的外形但是不影响字符的测量,会触发文本重新绘制但是不触发重新布局。
- ForegroundColorSpan,BackgroundColorSpan,UnderlineSpan,StrikethrougnSpan
- 字符大小布局,这种类型Span会更改文本的大小和布局,会触发文本的重新测量绘制
- StyleSpan,RelativeSizeSpan,AbsoluteSizeSpan
- 影响段落级别,这种类型Span 在段落级别起作用,更改文本块在段落级别的外观,修改对齐方式,边距等。
- AlignmentSpan,BulletSpan,QuoteSpan
- 实现基础样式 粗体、 斜体、 下划线 、中划线 的设置和取消。举个例子,对文本加粗,文字设置span样式注意要点,这里需要区分几种情况
- 当前选中区域不存在 bold 样式 这里我们选中BB。两种情况
- 当前区域紧靠左侧或者右侧不存在粗体样式: AABBCC 这时候直接设置 span即可
- 当前区域紧靠左侧或者右侧存在粗体样式如: AABBCC AABBCC AABBCC。这时候需要合并左右两侧的span,只剩下一个 span
- 当前选中区域存在了Bold 样式 选中 ABBC。四种情况:
- 选中样式两侧不存在连续的bold样式 AABBCC
- 选中内部两端存在连续的bold 样式 AABBCC
- 选中左侧存在连续的bold 样式 AABBCC
- 选中右侧存在连续的bold 样式 AABBCC
- 这时候需要合并左右两侧已经存在的span,只剩下一个 span
- 接下来逐步分解,然后处理span的逻辑顺序如下所示
- 首先对选中文字内容样式情况判断
- 边界判断与设置
- 取消Span(当我们选中的区域在一段连续的 Bold 样式里面的时候,再次选择Bold将会取消样式)
- 什么时候取消span呢,这个逻辑是比较复杂的,具体看看下面的举例。
- 当我们选中的区域在一段连续的 Bold 样式里面的时候,再次选择Bold将会取消样式
- 用户可以随意的删除文本,在删除过程中可能会出现如下的情况:
- 用户输入了 AABBCCDD
- 用户选择了粗体样式 AABBCCDD
- 用户删除了CC然后显示如下 : AABB DD
- 这个时候选中其中的BD 此时,在该区域中 存在两个span ,并且没有一个 span 完全包裹选中的 BD
- 在这种情况下 仍需要进行左右侧边界判断进行删除。这个具体可以看代码逻辑。
08.利用Span对文字属性处理
-
这里仅仅是对字体加粗进行介绍,其实设置span可以找到规律。多个span样式,考虑到后期的拓展性,肯定要进行封装和抽象,具体该如何处理呢?
-
设置文本选中内容加粗模式,代码如下所示,可以看到这里只需要传递一个lastFocusEdit对象即可,这个对象是最近被聚焦的EditText。
/**
-
修改加粗样式
*/
public void bold(EditText lastFocusEdit) {
//获取editable对象
Editable editable = lastFocusEdit.getEditableText();
//获取当前选中的起始位置
int start = lastFocusEdit.getSelectionStart();
//获取当前选中的末尾位置
int end = lastFocusEdit.getSelectionEnd();
HyperLogUtils.i(“bold select Start:” + start + " end: " + end);
if (checkNormalStyle(start, end)) {
return;
}
new BoldStyle().applyStyle(editable, start, end);
} -
然后如何调用这个,在HyperTextEditor类中代码如下所示。为何要这样写,可以把HyperTextEditor富文本类中设置span的逻辑放到SpanTextHelper类中处理,该类专门处理各种span属性,这样代码结构更加清晰,也方便后期增加更多span属性,避免一个类代码太臃肿。
/**
- 修改加粗样式
*/
public void bold() {
SpanTextHelper.getInstance().bold(lastFocusEdit);
}
public void applyStyle(Editable editable, int start, int end) {
//获取 从 start 到 end 位置上所有的指定 class 类型的 Span数组
E[] spans = editable.getSpans(start, end, clazzE);
E existingSpan = null;
if (spans.length > 0) {
existingSpan = spans[0];
}
if (existingSpan == null) {
//当前选中内部无此样式,开始设置span样式
checkAndMergeSpan(editable, start, end, clazzE);
} else {
//获取 一个 span 的起始位置
int existingSpanStart = editable.getSpanStart(existingSpan);
//获取一个span 的结束位置
int existingSpanEnd = editable.getSpanEnd(existingSpan);
if (existingSpanStart <= start && existingSpanEnd >= end) {
//在一个 完整的 span 中
//删除 样式
//
removeStyle(editable, start, end, clazzE, true);
} else {
//当前选中区域存在了某某样式,需要合并样式
checkAndMergeSpan(editable, start, end, clazzE);
}
}
}
09.如何设置插入多张图片
Observable.create(new ObservableOnSubscribe() {
@Override
public void subscribe(ObservableEmitter emitter) {
try{
hte_content.measure(0, 0);
List mSelected = Matisse.obtainResult(data);
// 可以同时插入多张图片
for (Uri imageUri : mSelected) {
String imagePath = HyperLibUtils.getFilePathFromUri(NewActivity.this, imageUri);
Bitmap bitmap = HyperLibUtils.getSmallBitmap(imagePath, screenWidth, screenHeight);
//压缩图片
imagePath = SDCardUtil.saveToSdCard(bitmap);
emitter.onNext(imagePath);
}
emitter.onComplete();
}catch (Exception e){
e.printStackTrace();
emitter.onError(e);
}
}
})
.subscribeOn(Schedulers.io())//生产事件在io
.observeOn(AndroidSchedulers.mainThread())//消费事件在UI线程
.subscribe(new Observer() {
@Override
public void onComplete() {
ToastUtils.showRoundRectToast(“图片插入成功”);
}
@Override
public void onError(Throwable e) {
ToastUtils.showRoundRectToast(“图片插入失败:”+e.getMessage());
}
@Override
public void onSubscribe(Disposable d) {
}
@Override
public void onNext(String imagePath) {
//插入图片
hte_content.insertImage(imagePath);
}
});
10.如何设置插入网络图片
- 插入图片有两种情况,一种是本地图片,一种是网络图片。由于富文本中对插入图片的宽高有限制,即可以动态设置图片的高度,这就要求请求网络图片后,需要对图片进行处理。
/**
- 在特定位置添加ImageView
*/
public void addImageViewAtIndex(final int index, final String imagePath) {
if (TextUtils.isEmpty(imagePath)){
return;
}
try {
imagePaths.add(imagePath);
final RelativeLayout imageLayout = createImageLayout();
HyperImageView imageView = imageLayout.findViewById(R.id.edit_imageView);
imageView.setAbsolutePath(imagePath);
HyperManager.getInstance().loadImage(imagePath, imageView, rtImageHeight);
layout.addView(imageLayout, index);
} catch (Exception e) {
e.printStackTrace();
}
}
HyperManager.getInstance().setImageLoader(new ImageLoader() {
@Override
public void loadImage(final String imagePath, final ImageView imageView, final int imageHeight) {
Log.e(“—”, "imageHeight: "+imageHeight);
//如果是网络图片
if (imagePath.startsWith(“http://”) || imagePath.startsWith(“https://”)){
//直接用图片加载框架加载图片即可
} else { //如果是本地图片
}
}
});
11.如何避免插入图片OOM
- 加载一个本地的大图片或者网络图片,从加载到设置到View上,如何减下内存,避免加载图片OOM。
- 在展示高分辨率图片的时候,最好先将图片进行压缩。压缩后的图片大小应该和用来展示它的控件大小相近,在一个很小的ImageView上显示一张超大的图片不会带来任何视觉上的好处,但却会占用相当多宝贵的内存,而且在性能上还可能会带来负面影响。
- 加载图片的内存都去哪里呢?
- 其实我们的内存就是去bitmap里了,BitmapFactory的每个decode函数都会生成一个bitmap对象,用于存放解码后的图像,然后返回该引用。如果图像数据较大就会造成bitmap对象申请的内存较多,如果图像过多就会造成内存不够用自然就会出现out of memory的现象。
- 为何容易OOM?
- 通过BitmapFactory的decode的这些方法会尝试为已经构建的bitmap分配内存,这时就会很容易导致OOM出现。为此每一种解析方法都提供了一个可选的BitmapFactory.Options参数,将这个参数的inJustDecodeBounds属性设置为true就可以让解析方法禁止为bitmap分配内存,返回值也不再是一个Bitmap对象,而是null。
- 如何对图片进行压缩?
- 1.解析图片,获取图片资源的属性
- 2.计算图片的缩放值
- 3.最后对图片进行质量压缩
public static Bitmap getSmallBitmap(String filePath, int newWidth, int newHeight) {
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(filePath, options);
// Calculate inSampleSize
// 计算图片的缩放值
options.inSampleSize = calculateInSampleSize(options, newWidth, newHeight);
// Decode bitmap with inSampleSize set
options.inJustDecodeBounds = false;
Bitmap bitmap = BitmapFactory.decodeFile(filePath, options);
// 质量压缩
Bitmap newBitmap = compressImage(bitmap, 500);
if (bitmap != null){
//手动释放资源
bitmap.recycle();
}
return newBitmap;
}
- 思考:inJustDecodeBounds这个参数是干什么的?
- 如果设置为true则表示decode函数不会生成bitmap对象,仅是将图像相关的参数填充到option对象里,这样我们就可以在不生成bitmap而获取到图像的相关参数了。
- 为何设置两次inJustDecodeBounds属性?
- 第一次:设置为true则表示decode函数不会生成bitmap对象,仅是将图像相关的参数填充到option对象里,这样我们就可以在不生成bitmap而获取到图像的相关参数。
- 第二次:将inJustDecodeBounds设置为false再次调用decode函数时就能生成bitmap了。而此时的bitmap已经压缩减小很多了,所以加载到内存中并不会导致OOM。
12.如何删除图片或者文字
/**
- 处理图片上删除的点击事件
- 删除类型 0代表backspace删除 1代表按红叉按钮删除
- @param view 整个image对应的relativeLayout view
*/
private void onImageCloseClick(View view) {
try {
//判断过渡动画是否结束,只能等到结束才可以操作
if (!mTransition.isRunning()) {
disappearingImageIndex = layout.indexOfChild(view);
//删除文件夹里的图片
List dataList = buildEditData();
HyperEditData editData = dataList.get(disappearingImageIndex);
if (editData.getImagePath() != null){
if (onHyperListener != null){
onHyperListener.onRtImageDelete(editData.getImagePath());
}
//SDCardUtil.deleteFile(editData.imagePath);
//从图片集合中移除图片链接
imagePaths.remove(editData.getImagePath());
}
//然后移除当前view
layout.removeView(view);
//合并上下EditText内容
mergeEditText();
}
} catch (Exception e) {
e.printStackTrace();
}
}
13.删除和插入图片添加动画
- 为什么要添加插入图片的过渡动画
- 当向一个ViewGroup添加控件或者移除控件;这种场景虽然能够实现效果,并没有一点过度效果,直来直去的添加或者移除,显得有点生硬。有没有办法添加一定的过度效果,让实现的效果显得圆滑呢?
- LayoutTransition简单介绍
- LayoutTransition类实际上Android系统中的一个实用工具类。使用LayoutTransition类在一个ViewGroup中对布局更改进行动画处理。
- 如何运用到插入或者删除图片场景中
- 向一个ViewGroup添加控件或者移除控件,这两种效果的过程是应对应于控件的显示、控件添加时其他控件的位置移动、控件的消失、控件移除时其他控件的位置移动等四种动画效果。这些动画效果在LayoutTransition中,由以下四个关键字做出了相关声明:
- APPEARING:元素在容器中显现时需要动画显示。
- CHANGE_APPEARING:由于容器中要显现一个新的元素,其它元素的变化需要动画显示。
- DISAPPEARING:元素在容器中消失时需要动画显示。
- CHANGE_DISAPPEARING:由于容器中某个元素要消失,其它元素的变化需要动画显示。
- 也就是说,ViewGroup中有多个ImageView对象,如果需要删除其中一个ImageView对象的话,该ImageView对象可以设置动画(即DISAPPEARING 动画形式),ViewGroup中的其它ImageView对象此时移动到新的位置的过程中也可以设置相关的动画(即CHANGE_DISAPPEARING 动画形式);
- 若向ViewGroup中添加一个ImageView,ImageView对象可以设置动画(即APPEARING 动画形式),ViewGroup中的其它ImageView对象此时移动到新的位置的过程中也可以设置相关的动画(即CHANGE_APPEARING 动画形式)。
- 给ViewGroup设置动画很简单,只需要生成一个LayoutTransition实例,然后调用ViewGroup的setLayoutTransition(LayoutTransition)函数就可以了。当设置了布局动画的ViewGroup添加或者删除内部view时就会触发动画。
mTransition = new LayoutTransition();
mTransition.addTransitionListener(new LayoutTransition.TransitionListener() {
@Override
public void startTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) {
}
@Override
public void endTransition(LayoutTransition transition,
ViewGroup container, View view, int transitionType) {
if (!transition.isRunning() && transitionType == LayoutTransition.CHANGE_DISAPPEARING) {
// transition动画结束,合并EditText
mergeEditText();
}
}
});
mTransition.enableTransitionType(LayoutTransition.APPEARING);
mTransition.setDuration(300);
layout.setLayoutTransition(mTransition);
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (mTransition!=null){
//移除Layout变化监听
mTransition.removeTransitionListener(transitionListener);
}
}
- 动画执行先后的顺序
- 分析源码可以知道,默认情况下DISAPPEARING和CHANGE_APPEARING类型动画会立即执行,其他类型动画则会有个延迟。也就是说如果删除view,被删除的view将先执行动画消失,经过一些延迟受影响的view会进行动画补上位置,如果添加view,受影响的view将会先给添加的view腾位置执行CHANGE_APPEARING动画,经过一些时间的延迟才会执行APPEARING动画。这里就不贴分析源码的思路呢!
14.点击图片可以查看大图
-
编辑状态时,由于图片有空能比较大,在显示在富文本的时候,会裁剪局中显示,也就是图片会显示不全。那么后期如果是想添加点击图片查看,则需要暴露给开发者监听事件,需要考虑到后期拓展性,代码如下所示:
-
这样做的目的是是暴露给外部开发者调用,点击图片的操作只需要传递view还有图片即可。
// 图片处理
btnListener = new OnClickListener() {
@Override
public void onClick(View v) {
if (v instanceof HyperImageView){
HyperImageView imageView = (HyperImageView)v;
// 开放图片点击接口
if (onHyperListener != null){
onHyperListener.onImageClick(imageView, imageView.getAbsolutePath());
}
}
}
};
15.如何暴露设置文字属性方法
/**
- 修改加粗样式
*/
public void bold() {
SpanTextHelper.getInstance().bold(lastFocusEdit);
}
/**
- 修改斜体样式
*/
public void italic() {
SpanTextHelper.getInstance().italic(lastFocusEdit);
}
/**
- 修改删除线样式
*/
public void strikeThrough() {
SpanTextHelper.getInstance().strikeThrough(lastFocusEdit);
}
/**
- 修改下划线样式
*/
public void underline() {
SpanTextHelper.getInstance().underline(lastFocusEdit);
}
public abstract class NormalStyle {
private Class clazzE;
public NormalStyle() {
//利用反射
clazzE = (Class) ((ParameterizedType) this.getClass().getGenericSuperclass()).getActualTypeArguments()[0];
}
/**
- 样式情况判断
- @param editable editable
- @param start start
- @param end end
*/
public void applyStyle(Editable editable, int start, int end) {
}
}
public class ItalicStyle extends NormalStyle {
@Override
protected ItalicStyleSpan newSpan() {
return new ItalicStyleSpan();
}
}
public class UnderlineStyle extends NormalStyle {
@Override
protected UnderLineSpan newSpan() {
return new UnderLineSpan();
}
}
16.文字中间添加图片注意事项
-
在文字中添加图片比较特殊,因此这里单独拿出来说一下。在文字内容中间插入图片,则需要分割字符串,分割成两个EditText,并在两个EditText中间插入图片,那么这个光标又定位在何处呢?
-
对于光标前面的字符串保留,设置给当前获得焦点的EditText(此为分割出来的第一个EditText)
-
把光标后面的字符串放在新创建的EditText中(此为分割出来的第二个EditText)
-
在第二个EditText的位置插入一个空的EditText,以便连续插入多张图片时,有空间写文字,第二个EditText下移
-
在空的EditText的位置插入图片布局,空的EditText下移。注意,这个过程添加动画过渡一下插入的效果比较好,不然会比较生硬
//获取光标所在位置
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则近万的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)
总结
Android架构学习进阶是一条漫长而艰苦的道路,不能靠一时激情,更不是熬几天几夜就能学好的,必须养成平时努力学习的习惯。所以:贵在坚持!
上面分享的字节跳动公司2020年的面试真题解析大全,笔者还把一线互联网企业主流面试技术要点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节。
就先写到这,码字不易,写的很片面不好之处敬请指出,如果觉得有参考价值的朋友也可以关注一下我
①「Android面试真题解析大全」PDF完整高清版+②「Android面试知识体系」学习思维导图压缩包阅读下载,最后觉得有帮助、有需要的朋友可以点个赞
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门即可获取!
24/03/13/H4lCoPEF.jpg" />
总结
Android架构学习进阶是一条漫长而艰苦的道路,不能靠一时激情,更不是熬几天几夜就能学好的,必须养成平时努力学习的习惯。所以:贵在坚持!
上面分享的字节跳动公司2020年的面试真题解析大全,笔者还把一线互联网企业主流面试技术要点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节。
就先写到这,码字不易,写的很片面不好之处敬请指出,如果觉得有参考价值的朋友也可以关注一下我
①「Android面试真题解析大全」PDF完整高清版+②「Android面试知识体系」学习思维导图压缩包阅读下载,最后觉得有帮助、有需要的朋友可以点个赞
[外链图片转存中…(img-dYx5YLGs-1711784887981)]
[外链图片转存中…(img-9toKOMwO-1711784887981)]
[外链图片转存中…(img-tEtAuCK3-1711784887981)]