android阅读器长按选择文字功能实现代码

发布时间 - 2026-01-11 02:12:53    点击率:

前言: 有时候我们需要实现长按选择文字功能,比如阅读器一般都有这个功能,有时候某个自定义控件上可能就有这种需求,如何实现呢?正好最近还算闲,想完善一下自己写的那个轻量级的txt文件阅读器(比如这个长按选择文字的功能就想加进去)。于是花了两三天时间,实现了这个功能,效果还是不错的。

首先先看看效果图吧:

授人以鱼不如授人以渔,下面具体实现原理的教程。

1.实现原理

原理其实也不难,简单总结就是:绘制文字时把显示的文字的坐标记录下来(记录文字的左上右上左下右下四个点坐标),作用就是为了计算滑动范围。执行了长按事件后,通过按的坐标,在当前显示的文字数据中根据点的坐标查找到按着的字,得到长按后选择的位置与文字。当执行滑动选择时,根据手指滑动的位置坐标与当前显示的文字数据匹配来确定选择的范围与文字。

2.具体实现

a.封装

为了便于操作,首先对显示可见的字符、显示的行数据进行封装。

ShowChar:

public class ShowChar {//可见字符数据封装

  public char chardata ;//字符数据
  public Boolean Selected =false;//当前字符是否被选中
  public Point TopLeftPosition = null;
  public Point TopRightPosition = null;
  public Point BottomLeftPosition = null;
  public Point BottomRightPosition = null;

  public float charWidth = 0;//字符宽度
  public int Index = 0;//当前字符位置


}

ShowLine :

public class ShowLine {//显示的行数据
  public List<ShowChar> CharsData = null;

  /**
   *@return
   *--------------------
   *TODO 获取该行的数据
   *--------------------
   */
  public String getLineData(){
    String linedata = "";  
    if(CharsData==null||CharsData.size()==0) return linedata;
    for(ShowChar c:CharsData){
      linedata = linedata+c.chardata;
    }
    return linedata;
  }
}

说明:阅读器显示数据是一行一行的,每行都有不确定数量的字符,每个字符有自己的信息,比如字符宽度、字符在数据集合中的下标等。绘制时,通过绘制ShowLine 去绘制每行的数据。

b.数据转化

绘制前,我们需要先要把数据转化为上面封装的格式数据以便我们使用。这个要怎么做?因为我们需要将字符串转化为一行一行的数据,同时每个字符的字符宽度需要测量出来。如果对绘制比较熟悉的话,应该会知道系统有个paint.measureText可以用来测量字符的宽度,这里可以借助这个来实现测量字符的宽度,同时转化为我们想要行数据。

首先,写个方法,可以将传入的字符串转化为行数据:

  /**
   *@param cs 
   *@param medsurewidth 行测量的最大宽度
   *@param textpadding 字符间距
   *@param paint 测量的画笔
   *@return 如果cs为空或者长度为0,返回null
   *--------------------
   *TODO 
   *--------------------
   */
  public static BreakResult BreakText(char[] cs, float medsurewidth, float textpadding, Paint paint) {  
    if(cs==null||cs.length==0){return null;}
    BreakResult breakResult = new BreakResult();    
    breakResult.showChars = new ArrayList<ShowChar>();
    float width = 0;

    for (int i = 0, size = cs.length; i < size; i++) {
      String mesasrustr = String.valueOf(cs[i]);
      float charwidth = paint.measureText(mesasrustr);

      if (width <= medsurewidth && (width + textpadding + charwidth) > medsurewidth) {
        breakResult.ChartNums = i;
        breakResult.IsFullLine = true;
        return breakResult;
      }

      ShowChar showChar = new ShowChar();
      showChar.chardata = cs[i];
      showChar.charWidth = charwidth;     
      breakResult.showChars.add(showChar);
      width += charwidth + textpadding;
    }

    breakResult.ChartNums = cs.length;
    return breakResult;
  }



public static BreakResult BreakText(String text, float medsurewidth, float textpadding, Paint paint) {
    if (TextUtils.isEmpty(text)) {
      int[] is = new int[2];
      is[0] = 0;
      is[1] = 0;
      return null;
    }
    return BreakText(text.toCharArray(), medsurewidth, textpadding, paint);

  }

说明: BreakResult 是对测量结果的简单封装:

public class BreakResult {

  public int ChartNums = 0;//测量了的字符数
  public Boolean IsFullLine = false;//是否满一行了
  public List<ShowChar> showChars = null;//测量了的字符数据

  public Boolean HasData() {
    return showChars != null && showChars.size() > 0;
  }
}

完成了上面的工作后,我们可以实现将我们显示的数据转化为需要的数据了。

下面是我们测试显示的字符串:

String TextData = "jEh话说天下大势,分久必合,合久必分。周末七国分争,并入于秦。及秦灭之后,楚、汉分争,又并入于汉。汉朝自高祖斩白蛇而起义,一统天下,后来光武中兴,传至献帝,遂分为三国。推其致乱之由,殆始于桓、灵二帝。桓帝禁锢善类,崇信宦官。及桓帝崩,灵帝即位,大将军窦武、太傅陈蕃共相辅佐。时有宦官曹节等弄权,窦武、陈蕃谋诛之,机事不密,反为所害,中涓自此愈横"
      +

  "建宁二年四月望日,帝御温德殿。方升座,殿角狂风骤起。只见一条大青蛇,从梁上飞将下来,蟠于椅上。帝惊倒,左右急救入宫,百官俱奔避。须臾,蛇不见了。忽然大雷大雨,加以冰雹,落到半夜方止,坏却房屋无数。建宁四年二月,洛阳地震;又海水泛溢,沿海居民,尽被大浪卷入海中。光和元年,雌鸡化雄。六月朔,黑气十余丈,飞入温德殿中。秋七月,有虹现于玉堂;五原山岸,尽皆崩裂。种种不祥,非止一端。帝下诏问群臣以灾异之由,议郎蔡邕上疏,以为堕鸡化,乃妇寺干政之所致,言颇切直。帝览奏叹息,因起更衣。曹节在后窃视,悉宣告左右;遂以他事陷邕于罪,放归田里。后张让、赵忠、封、段、曹节、侯览、蹇硕、程旷、夏恽、郭胜十人朋比为奸,号为“十常侍”。帝尊信张让,呼为“阿父”。朝政日非,以致天下人心思乱,盗贼蜂起。";

我们需要将这段字符串转化为行数据,在初始化数据的操作,下面是初始化数据的方法initData:

List<ShowLine> mLinseData = null;

  private void initData(int viewwidth, int viewheight) {
    if (mLinseData == null) {
      //将数据转化为行数据
      mLinseData = BreakText(viewwidth, viewheight);
    }

  }

  private List<ShowLine> BreakText(int viewwidth, int viewheight) {
    List<ShowLine> showLines = new ArrayList<ShowLine>();
    while (TextData.length() > 0) {
      BreakResult breakResult = TextBreakUtil.BreakText(TextData, viewwidth, 0, mPaint);

      if (breakResult != null && breakResult.HasData()) {
        ShowLine showLine = new ShowLine();
        showLine.CharsData = breakResult.showChars;
        showLines.add(showLine);

      } else {
        break;
      }

      TextData = TextData.substring(breakResult.ChartNums);

    }

    int index = 0;
    for (ShowLine l : showLines) {
      for (ShowChar c : l.CharsData) {
        c.Index = index++;
      }
    }
    return showLines;
  }

只要调用initData方法,我们就可以将TextData的数据转为显示的行数据Linedata集合mLinseData 。

值得注意的是,调用这个方法需求知道控件的长宽,根据view的生命周期,我们可以在onmeasures里面调用这个方法进行初始化。

@Override
  protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int viewwidth = getMeasuredWidth();
    int viewheight = getMeasuredHeight();
    initData(viewwidth, viewheight);
  }

数据转化完成后,接着我们需要把数据一行一行的绘制出来:

  @Override
  protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);  

    LineYPosition = TextHeight + LinePadding;//第一行显示的y坐标
    for (ShowLine line : mLinseData) {
      DrawLineText(line, canvas);//绘制每一行,并记录每个字符的坐标
    }
  }

DrawLineText方法:

private void DrawLineText(ShowLine line, Canvas canvas) {
    canvas.drawText(line.getLineData(), 0, LineYPosition, mPaint);

    float leftposition = 0;
    float rightposition = 0;
    float bottomposition = LineYPosition + mPaint.getFontMetrics().descent;

    for (ShowChar c : line.CharsData) {
      rightposition = leftposition + c.charWidth;
      Point tlp = new Point();
      c.TopLeftPosition = tlp;
      tlp.x = (int) leftposition;
      tlp.y = (int) (bottomposition - TextHeight);

      Point blp = new Point();
      c.BottomLeftPosition = blp;
      blp.x = (int) leftposition;
      blp.y = (int) bottomposition;

      Point trp = new Point();
      c.TopRightPosition = trp;
      trp.x = (int) rightposition;
      trp.y = (int) (bottomposition - TextHeight);

      Point brp = new Point();
      c.BottomRightPosition = brp;
      brp.x = (int) rightposition;
      brp.y = (int) bottomposition;
      leftposition = rightposition;

    }
    LineYPosition = LineYPosition + TextHeight + LinePadding;
  }

运行一下,目前显示效果如下:

实现这些后,接下来需要实现长按选择功能以及滑动选择文字功能。如何实现长按呢,自己写肯定可以,只是也太麻烦了,所以我们这里借助系统提供的长按事件就可以。我实现的思路是这样的,首先先将事件处理模式分四种:

private enum Mode {

    Normal, //正常模式
    PressSelectText,//长按选中文字
    SelectMoveForward, //向前滑动选中文字
    SelectMoveBack//向后滑动选中文字
  }

在没有做任何处理情况下是Normal模式,如果手势发生了,Down事件触发,记录当前Down的坐标,如果用户一直按着,必然触发长按事件,模式转化为PressSelectText,通过记录的Down的坐标,去数据集合中找到当前长按的字符,绘画出选择的文字的背景。

思路是这样,那么就干吧。首先注册长按事件,在初始化使注册该事件。

private void init() {
    mPaint = new Paint();
    mPaint.setAntiAlias(true);
    mPaint.setTextSize(29);

    mTextSelectPaint = new Paint();
    mTextSelectPaint.setAntiAlias(true);
    mTextSelectPaint.setTextSize(19);
    mTextSelectPaint.setColor(TextSelectColor);

    mBorderPointPaint = new Paint();
    mBorderPointPaint.setAntiAlias(true);
    mBorderPointPaint.setTextSize(19);
    mBorderPointPaint.setColor(BorderPointColor);

    FontMetrics fontMetrics = mPaint.getFontMetrics();
    TextHeight = Math.abs(fontMetrics.ascent) + Math.abs(fontMetrics.descent);

    setOnLongClickListener(mLongClickListener);

  }
private OnLongClickListener mLongClickListener = new OnLongClickListener() {

    @Override
    public boolean onLongClick(View v) {

      if (mCurrentMode == Mode.Normal) {
        if (Down_X > 0 && Down_Y > 0) {// 说明还没释放,是长按事件
          mCurrentMode = Mode.PressSelectText;
          postInvalidate();//刷新
        }
      }
      return false;
    }
  };

这里 Down_X , Down_Y ; 初始化值都是-1,如果执行了down事件后它们肯定大于0,如果执行了Action_up事件,释放设置值为-1,只是为了判断使用而已。

然后onDraw中需要判断一下并绘制选择的文字了。

@Override
  protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    LineYPosition = TextHeight + LinePadding;//第一行的y坐标
    for (ShowLine line : mLinseData) {
      DrawLineText(line, canvas);//绘制每一
    }

    if (mCurrentMode != Mode.Normal) {
      DrawSelectText(canvas);//如果不是正常的话,绘制选择
    }
  }
private void DrawSelectText(Canvas canvas) {
    if (mCurrentMode == Mode.PressSelectText) {
      DrawPressSelectText(canvas);//绘制长按选择的字符
    } else if (mCurrentMode == Mode.SelectMoveForward) {//向前滑动选择
      DrawMoveSelectText(canvas);//绘制滑动时选择的文字背景
    } else if (mCurrentMode == Mode.SelectMoveBack) {//向后滑动选择
      DrawMoveSelectText(canvas);//绘制滑动时选择的文字背景
    }
  }

这时如果执行了长按事件,mCurrentMode == Mode.PressSelectText,将执行绘制长按选择的字符。

     //绘制长按选中的数据
private void DrawPressSelectText(Canvas canvas) {
    //根据按的坐标检测找到长按的字符
    ShowChar p = DetectPressShowChar(Down_X, Down_Y);

    if (p != null) {// 找到了选择的字符
      FirstSelectShowChar = LastSelectShowChar = p;
      mSelectTextPath.reset();
      mSelectTextPath.moveTo(p.TopLeftPosition.x, p.TopLeftPosition.y);
      mSelectTextPath.lineTo(p.TopRightPosition.x, p.TopRightPosition.y);
      mSelectTextPath.lineTo(p.BottomRightPosition.x, p.BottomRightPosition.y);
      mSelectTextPath.lineTo(p.BottomLeftPosition.x, p.BottomLeftPosition.y);
      //绘制文字背景
      canvas.drawPath(mSelectTextPath, mTextSelectPaint);
      //绘制边界的线与指示块
      DrawBorderPoint(canvas);

    }
  }

检测点击点所在的字符方法:

  /**
   *@param down_X2
   *@param down_Y2
   *@return
   *--------------------
   *TODO 检测获取按压坐标所在位置的字符,没有的话返回null
   *--------------------
   */
  private ShowChar DetectPressShowChar(float down_X2, float down_Y2) {

    for (ShowLine l : mLinseData) {
      for (ShowChar c : l.CharsData) {
        if (down_Y2 > c.BottomLeftPosition.y) {
          break;// 说明是在下一行
        }
        if (down_X2 >= c.BottomLeftPosition.x && down_X2 <= c.BottomRightPosition.x) {
          return c;
        }

      }
    }

    return null;
  }

基本上长按事件操作都完成了,我们运行长按文字看看效果:

绘制了长按选择的字符后,我们需要实现按着左右的指示块进行左右或者上下滑动去选择文字。为了便于操作,向上滑动与向下滑动都有限制滑动范围,如下图:

蓝色的区域是手指按着后触发允许滑动。按着左边的小蓝色区域,mCurrentMode == Mode.SelectMoveForward,允许向上滑动选择文字,就是手指滑动坐标滑动到黄色区域有效。按着右边的小蓝色区域,mCurrentMode == Mode.SelectMoveBack,允许向下滑动选择文字,就是手指滑动到绿色区域有效。

选择时,我们只会记录两个字符,就是选择的文字的开始字符与结束字符:

private ShowChar FirstSelectShowChar = null;
private ShowChar LastSelectShowChar = null;

注意的是当长按选择一个字符后:FirstSelectShowChar = LastSelectShowChar;

所以整个过程是:滑动时,如果按着左边的蓝色区域,将允许向前滑动,这时mCurrentMode == Mode.SelectMoveForward,向前滑动即在黄色区域滑动,这时就可以根据手指滑动坐标找到滑动后的FirstSelectShowChar ,然后刷新界面。向下滑动同理。

下面是代码实现:

先在Action_Down里判断是向下滑动还是向下滑动,如果都不是,重置,使长按选择的文字恢复原样。

case MotionEvent.ACTION_DOWN:
      Down_X = Tounch_X;
      Down_Y = Tounch_Y;

      if (mCurrentMode != Mode.Normal) {
        Boolean isTrySelectMove = CheckIfTrySelectMove(Down_X, Down_Y);

        if (!isTrySelectMove) {// 如果不是准备滑动选择文字,转变为正常模式,隐藏选择框
          mCurrentMode = Mode.Normal;
          invalidate();
        }
      }

      break;

在滑动时判断,如果是向上滑动,检测获取当前滑动时的FirstSelectShowChar ;如果是向下滑动,检测获取当前滑动时的LastSelectShowChar ,然后刷新界面。

case MotionEvent.ACTION_MOVE:
      if (mCurrentMode == Mode.SelectMoveForward) {
        if (CanMoveForward(event.getX(), event.getY())) {// 判断是否是向上移动
          ShowChar firstselectchar = DetectPressShowChar(event.getX(), event.getY());//获取当前滑动坐标的下的字符
          if (firstselectchar != null) {
            FirstSelectShowChar = firstselectchar;
            invalidate();
          } 

        }

      } else if (mCurrentMode == Mode.SelectMoveBack) {

        if (CanMoveBack(event.getX(), event.getY())) {// 判断是否可以向下移动         
          ShowChar lastselectchar = DetectPressShowChar(event.getX(), event.getY());//获取当前滑动坐标的下的字符
          if (lastselectchar != null) {
            LastSelectShowChar = lastselectchar;
            invalidate();
          } 

        } 
      }

      break;

判断是否向上滑动方法:

private boolean CanMoveForward(float Tounchx, float Tounchy) {

    Path p = new Path();
    p.moveTo(LastSelectShowChar.TopRightPosition.x, LastSelectShowChar.TopRightPosition.y);
    p.lineTo(getWidth(), LastSelectShowChar.TopRightPosition.y);
    p.lineTo(getWidth(), 0);
    p.lineTo(0, 0);
    p.lineTo(0, LastSelectShowChar.BottomRightPosition.y);
    p.lineTo(LastSelectShowChar.BottomRightPosition.x, LastSelectShowChar.BottomRightPosition.y);
    p.lineTo(LastSelectShowChar.TopRightPosition.x, LastSelectShowChar.TopRightPosition.y);

    return computeRegion(p).contains((int) Tounchx, (int) Tounchy);
  }

判断是否向下滑动:

private boolean CanMoveBack(float Tounchx, float Tounchy) {

    Path p = new Path();
    p.moveTo(FirstSelectShowChar.TopLeftPosition.x, FirstSelectShowChar.TopLeftPosition.y);
    p.lineTo(getWidth(), FirstSelectShowChar.TopLeftPosition.y);
    p.lineTo(getWidth(), getHeight());
    p.lineTo(0, getHeight());
    p.lineTo(0, FirstSelectShowChar.BottomLeftPosition.y);
    p.lineTo(FirstSelectShowChar.BottomLeftPosition.x, FirstSelectShowChar.BottomLeftPosition.y);
    p.lineTo(FirstSelectShowChar.TopLeftPosition.x, FirstSelectShowChar.TopLeftPosition.y);

    return computeRegion(p).contains((int) Tounchx, (int) Tounchy);
  }


private Region computeRegion(Path path) {
    Region region = new Region();
    RectF f = new RectF();
    path.computeBounds(f, true);
    region.setPath(path, new Region((int) f.left, (int) f.top, (int) f.right, (int) f.bottom));
    return region;
  }

手势操作处理完成了,剩下的就是在ondraw时判断到mCurrentMode == Mode.SelectMoveForward或者mCurrentMode == Mode.SelectMoveBack绘制出选择的范围背景。

private void DrawSelectText(Canvas canvas) {
    if (mCurrentMode == Mode.PressSelectText) {
      DrawPressSelectText(canvas);//绘制长按选择的字符
    } else if (mCurrentMode == Mode.SelectMoveForward) {//向前滑动选择
      DrawMoveSelectText(canvas);//绘制滑动时选择的文字背景
    } else if (mCurrentMode == Mode.SelectMoveBack) {//向后滑动选择
      DrawMoveSelectText(canvas);//绘制滑动时选择的文字背景
    }
  }

private void DrawMoveSelectText(Canvas canvas) {
    if (FirstSelectShowChar == null || LastSelectShowChar == null)     return;
    GetSelectData();//获取选择字符的数据,转化为选择的行数据
    DrawSeletLines(canvas);//绘制选择的行数据
    DrawBorderPoint(canvas);//绘制出边界的方块或圆点
  }

private void DrawSeletLines(Canvas canvas) 
    DrawOaleSeletLinesBg(canvas);
  }

  private void DrawOaleSeletLinesBg(Canvas canvas) {// 绘制椭圆型的选中背景
    for (ShowLine l : mSelectLines) {      
      if (l.CharsData != null && l.CharsData.size() > 0) {        
        ShowChar fistchar = l.CharsData.get(0);
        ShowChar lastchar = l.CharsData.get(l.CharsData.size() - 1);

        float fw = fistchar.charWidth;
        float lw = lastchar.charWidth;

        RectF rect = new RectF(fistchar.TopLeftPosition.x, fistchar.TopLeftPosition.y,
            lastchar.TopRightPosition.x, lastchar.BottomRightPosition.y);

        canvas.drawRoundRect(rect, fw / 2,
             TextHeight / 2, mTextSelectPaint);

      }
    }
  }

基本完成了,运行一下,效果还是不错的。


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。


# android  # 长按选择文字  # 长按  # 长按复制  # Android WebView如何判定网页加载的错误  # Android webView字体突然变小的原因及解决  # Android 解决WebView多进程崩溃的方法  # Android 中 WebView 的基本用法详解  # 在Android环境下WebView中拦截所有请求并替换URL示例详解  # 解决Android webview设置cookie和cookie丢失的问题  # Android 如何从零开始写一款书籍阅读器的示例  # Android实现阅读进度记忆功能  # android仿新闻阅读器菜单弹出效果实例(附源码DEMO下载)  # Android实现阅读APP平移翻页效果  # Android编程实现小说阅读器滑动效果的方法  # Android使用WebView实现离线阅读功能  # 转化为  # 行数  # 按着  # 都有  # 建宁  # 的是  # 判断是否  # 完成了  # 就可以  # 是这样  # 崇信  # 如果不是  # 朋比为奸  # 如何实现  # 自己的  # 都是  # 授人  # 入于  # 是在  # 还没 


相关栏目: 【 网站优化151355 】 【 网络推广146373 】 【 网络技术251813 】 【 AI营销90571


相关推荐: 标准网站视频模板制作软件,现在有哪个网站的视频编辑素材最齐全的,背景音乐、音效等?  Laravel如何使用软删除(Soft Deletes)功能_Eloquent软删除与数据恢复方法  作用域操作符会触发自动加载吗_php类自动加载机制与::调用【教程】  Laravel全局作用域是什么_Laravel Eloquent Global Scopes应用指南  phpredis提高消息队列的实时性方法(推荐)  什么是javascript作用域_全局和局部作用域有什么区别?  Laravel Fortify是什么,和Jetstream有什么关系  javascript中数组(Array)对象和字符串(String)对象的常用方法总结  Laravel怎么使用Markdown渲染文档_Laravel将Markdown内容转HTML页面展示【实战】  微信公众帐号开发教程之图文消息全攻略  Laravel软删除怎么实现_Laravel Eloquent SoftDeletes功能使用教程  公司门户网站制作流程,华为官网怎么做?  免费网站制作appp,免费制作app哪个平台好?  如何在橙子建站上传落地页?操作指南详解  如何确保西部建站助手FTP传输的安全性?  Laravel如何实现数据导出到PDF_Laravel使用snappy生成网页快照PDF【方案】  如何在云指建站中生成FTP站点?  C++时间戳转换成日期时间的步骤和示例代码  标题:Vue + Vuex 项目中正确使用 JWT 进行身份认证的实践指南  如何快速搭建高效简练网站?  Linux网络带宽限制_tc配置实践解析【教程】  矢量图网站制作软件,用千图网的一张矢量图做公司app首页,该网站并未说明版权等问题,这样做算不算侵权?应该如何解决?  Android中AutoCompleteTextView自动提示  悟空识字如何进行跟读录音_悟空识字开启麦克风权限与录音  html5如何实现懒加载图片_ intersectionobserver api用法【教程】  Laravel用户认证怎么做_Laravel Breeze脚手架快速实现登录注册功能  儿童网站界面设计图片,中国少年儿童教育网站-怎么去注册?  东莞专业网站制作公司有哪些,东莞招聘网站哪个好?  python中快速进行多个字符替换的方法小结  Laravel如何使用Blade组件和插槽?(Component代码示例)  C语言设计一个闪闪的圣诞树  UC浏览器如何切换小说阅读源_UC浏览器阅读源切换【方法】  学生网站制作软件,一个12岁的学生写小说,应该去什么样的网站?  如何批量查询域名的建站时间记录?  太平洋网站制作公司,网络用语太平洋是什么意思?  Laravel如何使用Eloquent ORM进行数据库操作?(CRUD示例)  Android Socket接口实现即时通讯实例代码  laravel怎么为应用开启和关闭维护模式_laravel应用维护模式开启与关闭方法  Android实现代码画虚线边框背景效果  轻松掌握MySQL函数中的last_insert_id()  Laravel如何实现邮箱地址验证功能_Laravel邮件验证流程与配置  简单实现Android验证码  Laravel怎么配置S3云存储驱动_Laravel集成阿里云OSS或AWS S3存储桶【教程】  北京网站制作公司哪家好一点,北京租房网站有哪些?  Laravel观察者模式如何使用_Laravel Model Observer配置  ChatGPT常用指令模板大全 新手快速上手的万能Prompt合集  Laravel用户密码怎么加密_Laravel Hash门面使用教程  制作ppt免费网站有哪些,有哪些比较好的ppt模板下载网站?  如何生成腾讯云建站专用兑换码?  深入理解Android中的xmlns:tools属性