展开菜单
首页 精品内容 本月促销 装机必备 Windows PDF教程 macOS软件 IOS软件 Android AI 专题
全部分类

当前位置:

首页 > 编程开发 > SpringBoot实现无感刷新token的具体方法

SpringBoot实现无感刷新token的具体方法

前言 Token无感刷新,简单说,就是让用户在无感知的情况下,自动把访问令牌(Token)更新一下,维持住登录状态。听起来挺美,但真正动手做起来,坑不少。 常见的做法是:用短期的token做权限认证,再配一个更长期的refreshToken专职负责刷新。但这条路走下来,总会撞上几个经典问题: Q1:

本文目录

    前言

    Token无感刷新,简单说,就是让用户在无感知的情况下,自动把访问令牌(Token)更新一下,维持住登录状态。听起来挺美,但真正动手做起来,坑不少。

    常见的做法是:用短期的token做权限认证,再配一个更长期的refreshToken专职负责刷新。但这条路走下来,总会撞上几个经典问题:

    • Q1: 这事儿到底该在服务端干,还是客户端就能搞定?
    • Q2: token过期后解析不了,那它里面的过期时间咋取出来?
    • Q3: 无感刷新的核心是——拿到新token后,得把之前失败的请求重新发出去,还得把结果正确返给调用方。这个流程怎么接得上?

    下面针对这些问题,梳理一下可以参考的思路。

    一、客户端实现

    1.1 初始版本

    思路是这样:每次客户端发请求,服务端的gateway先拦下来,判断token是否过期。过期了,返回一个特定的状态码(比如511)通知客户端;没过期,放行,正常走业务逻辑。

    前端那边呢,拦截响应状态码,根据码值决定下一步动作。于是,axios拦截器就登场了。

    1.1.1 服务端gateway拦截器

    环境是SpringBoot3 + Ja va17,通过继承GlobalFilter来实现过滤逻辑。

    @Component
    public class MyAccessFilter implements GlobalFilter, Ordered
    {
        @Override
        public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
            ServerHttpRequest request = exchange.getRequest();
            String uri = request.getURI().getPath();
            HttpMethod method = request.getMethod();
    
            // OPTION直接放行
            if(method.matches(HttpMethod.OPTIONS.name()))
                return chain.filter(exchange);
    
            // 登录请求直接放行
            if(SecurityAccessConstant.REQUEST_LOGGING_URI.equals(uri) && method.matches(HttpMethod.POST.name()))
                return chain.filter(exchange);
       
            // 获取token
            String token = JWTHelper.getToken(request.getHeaders().getFirst(SecurityAccessConstant.HEADER_NAME_TOKEN));
            if(null != token){
                // 判断token是否过时
                if(!JWTHelper.isOutDate(token)){
                    return chain.filter(exchange);
                } else {
                    if(!SecurityAccessConstant.REQUEST_REFRESH.equals(uri))    // 当前不是刷新请求,返回511
                        return ResponseUtils.out(exchange , ResultData.fail(ResultCodeEnum.NEED_TO_REFRESH_TOKEN.getCode(),
                            ResultCodeEnum.NEED_TO_REFRESH_TOKEN.getMessage()));
    
                    // 当前是刷新请求,但refreshToken都过期了
                    return ResponseUtils.out(exchange , ResultData.fail(ResultCodeEnum.RC401.getCode(), ResultCodeEnum.RC401.getMessage()));
                }
            }
            
            return ResponseUtils.out(exchange , ResultData.fail(ResultCodeEnum.RC401.getCode(), ResultCodeEnum.RC401.getMessage()));
        }
    
        @Override
        public int getOrder() {
            return Ordered.LOWEST_PRECEDENCE;
        }
    }
    

    1.1.1.1 解答Q2:怎么获取过期时间?

    正常情况下解析token会报错。所以在解析的时候截获异常,如果catch到JwtException,就认为token无效过期了,返回true。否则正常解析,取出过期时间和当前时间比较。

    // 判断当前token是否过期
    public static boolean isOutDate(String token){
        try {
            Jws claimsJws = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token);
            Date expirationDate = claimsJws.getBody().getExpiration();
            return expirationDate.before(new Date());
        } catch (JwtException e) {
            return true;
        }
    }
    

    1.1.2 axios拦截器

    在拦截器里,根据响应码做判断。401就清空用户数据,跳到登录页;511就用refreshToken去刷新。

    // 响应拦截器
    service.interceptors.response.use(
      function(response) {
        console.log(response)
        return response.data.data;
      },
      async function(error) {
        if(error.response == undefined)
          return Promise.reject(error);
    
        const status = error.response.status
        const authStore = useAuthStore()
        let message = ''
    
        switch(status){
          case 401:
            authStore.reset()
            window.sessionStorage.removeItem('isAuthenticated')
            window.sessionStorage.removeItem('token')
            window.sessionStorage.removeItem('refreshToken')
            message = 'token 失效,请重新登录'
            window.location.href = '/auth/login';
            break;
          case 511:
            try {
              const data = refresh()
              if(data !== null){
                data.then((value) => {
                  if(value !== ''){
                    console.log("刷新 token 成功", value);
                    window.sessionStorage.setItem("token",value)
                    error.config.headers['Authorization'] = 'Bearer ' +value;
                    return service(error.config);
                  }
                  console.log(value);
                }).catch((error) => {
                  console.error(error);
                });
              }
            } catch (err) {
              console.log("请求刷新 token 失败", err);
              router.push("/login");
            }
            break;
          case '403':
            message = '拒绝访问'
            break;
          case '404':
            message = '请求地址错误'
            break;
          case '500':
            message = '服务器故障'
            break;
          default:
            message = '网络连接故障'   
        }
        Message.error(message)
        return Promise.reject(error);
      }
    );
    

    1.1.3 refresh刷新token的方法

    这里直接用axios原声发异步请求,而不是用request.ts里导出的方法。因为那个方法自带请求拦截器,每次请求前都会自动带上token——但这里要刷新token,老token已经失效了,再用它就会死循环。

    /**
     * 刷新token 
     * 成功返回新token,失败返回空字符串''
     */
    export async function refresh() : Promise{
      const refreshToken = window.sessionStorage.getItem("refreshToken")
      console.log("in >>> " ,refreshToken)
      if(refreshToken == undefined)
          return ''
      
      try {
        const response = await axios({
          method: 'GET',
          url: 'http://127.0.0.1:9001/api/simple/cloud/access/refresh',
          headers: {
            Authorization: `Bearer ${refreshToken}`,
          },
        });
        if (response.data) {
          return response.data.data;
        } else {
          return '';
        }
      } catch (error) {
        console.log(error);
        return '';
      }
    }  
    

    1.1.4 正常与异常情况下的控制台输出分析

    代码里塞了不少console.log,跑起来能更直观地看流程。结合测试结果一起分析。

    下面的截图是正常情况。注意这里token以hizFIGg结尾,refreshToken以suvm-EgQ结尾。正常返回就是200 OK。

    SpringBoot实现无感刷新token的具体方法

    再看看异常情况。因为token太长拆了两张图。从左图开始分析:

    • 发起第一次请求后,后端gateway拦截器报511(对应case 511,触发刷新流程)。
    • in >>>进入refresh方法,成功打出refreshToken,仍是suvm-EgQ结尾。
    • 接着打出“刷新token成功”,返回的是新token,用它替换旧的并重发请求。

    左图分析完毕,进入右图:

    • 用新token发请求,请求拦截器捕获到的token已更新,以V0dYcMA结尾;refreshToken仍是suvm-EgQ(说明refreshToken只用来刷新,本身不刷新)。
    • 但是注意到“Uncaught error status 511”——这不就是一开始的错误吗?实际上,原按钮点击事件调用的getAllUser方法已经结束,返回的结果就是那个511 error。而refresh是异步调用,它的执行穿插在里面。

    SpringBoot实现无感刷新token的具体方法

    SpringBoot实现无感刷新token的具体方法

    最终结果里,没有出现之前“>>>>>”那部分输出。用新token发第二次请求得到的数据,已经无法返回到原来的getAllUser方法调用处——因为原方法早就结束了。通俗点说,这个二次调用结果毫无意义,用户还是得手动刷新页面或再点一次。

    这就是Q3的核心问题:异步调用导致原方法提前终止。如果改成阻塞式调用,响应会变慢,影响用户体验。有更好的想法欢迎讨论。

    1.2 改进版本

    既然异步路子走不通,换个思路如何:不在失败时去刷新,而是提前检查本地存储的token有没有过期。如果发现token剩余时间小于某个临界点,就主动异步调用刷新方法,更新本地token。这样一来,只要服务端gateway拦截到token失效的请求,直接要求重新登录就行。关键点在于引入一个定时器。

    在TypeScript里,定时器主要通过setInterval和setTimeout实现。这里用setInterval,按指定间隔重复执行某段逻辑。

    当setInterval被调用时,它从被调用那一刻开始计时,每隔指定毫秒数就执行一次。所以这个定时器应该在登录成功后启动。

    1.2.1 定义定时器类

    通过这个类,MyTimer.start方法启动setInterval,每隔delay毫秒检查一次,判断当前token剩余时间是否小于minCheck,如果小于,就异步刷新。

    import { refresh } from "@/api/system/auth/index"
    import { jwtDecode } from "jwt-decode";
    
    export class MyTimer {
      private timerId: any | null = null;
      
       start(delay: number, minCheck : number): void {
         this.timerId = setInterval(async () => {
           const currentToken = window.sessionStorage.getItem('token');
           console.log("timer++++")
           if (currentToken) {
             let expirationTime = 0;
             expirationTime = getExpirationTime(currentToken);
             const timeRemaining = expirationTime - Date.now();
             if (timeRemaining <= minCheck) {
               await refresh();
             }
           } else {
             await refresh();
           }
       }, delay);
       }
     
       stop(): void {
         if (this.timerId !== null) {
           clearInterval(this.timerId);
           this.timerId = null;
         }
       }
     }
    
     function getExpirationTime(rawToken:string) : number{
       const res = jwtDecode(rawToken)
       return res.exp as number
     }
    

    1.2.2 修改Login点击事件

    只关注新增的定时器部分,其他是权限和token的常规存储逻辑。

    import { MyTimer } from "@/utils/tokenMonitor"
    
    const submit = () => {
      if (validate()) {
        login(formData)
          .then((data: UserInfoRes) => {
            if (data) {
              const token = data.token;
              const authStore = useAuthStore()
              authStore.setToken(token)
              window.sessionStorage.setItem('token', token)
              window.sessionStorage.setItem('refreshToken', data.refreshToken)
              authStore.setIsAuthenticated(true)
              window.sessionStorage.setItem('isAuthenticated', 'true')
              authStore.setName(data.name)
              authStore.setButtons(data.buttons)
              authStore.setRoles(data.roles)
              authStore.setRouters(data.routers)
              // 引入计时器
              const clock = new MyTimer();
              clock.start(1000*30,1000*30);
    
              init({ message: "logged in success", color: 'success' });
              push({ name: 'dashboard' })
            }
          })
          .catch(() => {
            init({ message: "logged in fail , please check carefully!", color: '#FF0000' });
          });
      } else {
        Message.error('error submit!!')
        return false
      }
    }
    

    1.2.3 测试

    照理说应该没问题,能正确解析token。但一跑就报错了:

    InvalidTokenError: Invalid token specified: invalid json for part #2

    换成jwt.verify()用密钥解码也不行,页面都加载不出来。

    SpringBoot实现无感刷新token的具体方法

    翻了不少资料后总结出原因:后端token是用jjwt库生成的,不同库的底层编码实现有差异,导致a库加密的token无法被b库解密。

    找到了病根,解决方案就清晰了:要么用与jjwt逻辑一致的库来解码,要么换个思路——服务端下发token时,直接把过期时间也带上。前端省去解码这一步,问题迎刃而解。下面引出最终版本。

    1.3 最终定时器版本

    1.3.1 服务端修改

    1.3.1.1 获取token过期时间

    // 获取当前token过期时间,不判断是否过期(因为进来时已经判断过了)
    public static Date getExpirationDate(String token) {
        if(StringUtil.isBlank(token))
            return null;
    
        Claims claims = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token).getBody();
        return claims.getExpiration();
    }
    

    1.3.1.2 发放token时携带过期时间

    // 存放token到请求头中
    String[] tokenArray = JWTHelper.createToken(sysUser.getId(), sysUser.getEmail(), permsList);
    map.put("token",tokenArray[0]);
    // 新增设置过期时间(毫秒数)
    map.put("tokenExpire",JWTHelper.getExpirationDate(tokenArray[0]).getTime());
    map.put("refreshToken",tokenArray[1]);
    

    同样,在refreshToken接口返回时也带上新的过期时间。

    1.3.2 修改监控器类MyTimer

    最终版本的类包含三个属性:timerId(定时器唯一ID)、delay(执行间隔)、minCheck(过期时间阈值)。采用单例模式,全局导出一份实例便于管理。token的过期时间直接从服务端获取,和当前时间比较即可。

    import { refresh } from "@/api/system/auth/index"
    
    class MyTimer {
        private timerId: any | null = null;
        private delay: number;
        private minCheck: number;
    
        private static instance: MyTimer;
    
        public static getInstance(): MyTimer {
          if (!MyTimer.instance) {
            MyTimer.instance = new MyTimer();
          }
          return MyTimer.instance;
        }
    
        private constructor() {
          this.delay = 30000;
          this.minCheck = 60000;
        }
    
        start(): void {
          this.timerId = setInterval(async () => {
            const currentToken = window.sessionStorage.getItem('token');
            console.log("timer++++",currentToken)
            if (currentToken) {
              const tokenExpireStr = window.sessionStorage.getItem('tokenExpire') as string
              const expirationTime = parseInt(tokenExpireStr, 10);
              const timeRemaining = expirationTime - Date.now();
              console.log("ttime sub++++",timeRemaining)
              if (timeRemaining <= this.minCheck) {
                try{
                  await refresh();
                } catch (error) {
                  console.error('刷新失败:', error);
                  window.sessionStorage.removeItem('isAuthenticated')
                  window.sessionStorage.removeItem('token')
                  window.sessionStorage.removeItem('refreshToken')
                  Message.error("token refresh got some problem, please login")
                  window.location.href = '/auth/login';
                }
              }
            } else {
              Message.error("token invalidate, please login")
              window.location.href = '/auth/login';
            }
        }, this.delay);
    
        console.log(this.timerId)
        }
    
        stop(): void {
          if (this.timerId !== null) {
            clearInterval(this.timerId);
            this.timerId = null;
          }
        }
    
        setDelay(delay: number): void {
          this.delay = delay;
        }
      
        setMinCheck(minCheck: number): void {
          this.minCheck = minCheck;
        }
    }
    
    export const myFilterInstance = MyTimer.getInstance();
    
    export function onPageRender(){
        myFilterInstance.stop();
        myFilterInstance.start();
    }
    

    1.3.3 onPageRender 的使用

    注意最后这个方法。测试中发现,通过导航栏切换页面时,定时器会被kill掉,导致token无法刷新,新请求会报错。因此最好在每个页面挂载时调用onPageRender,重启定时器,确保token一直有“人”照看。

    import { onPageRender } from '@/utils/tokenMonitor'
    
    window.addEventListener('load', () => {
      onPageRender();
    });
    

    1.3.4 测试

    最终的测试结果如下(可以结合代码中的输出语句来看):

    • 红色框框是监控器触发后输出的日志,每次都会比对token的过期时间是否小于阈值。刷新后还会用新的过期时间继续比较。

    一旦小于阈值(这里设置1分钟 = 60000ms),就进入refresh逻辑,和上面讲的一样。这样就能保证每次请求携带的token大概率都是最新的。客户端实现的思路到这里就完整讲完了。

    SpringBoot实现无感刷新token的具体方法

    2. 服务端实现

    这种思路是:在gateway拦截token时,如果发现过期,就通过WebClient携带refreshToken异步请求认证服务器获取新token。下面的代码实现了发起请求到获取数据的过程,但没有实现原请求的再发送(这个坑后续再填)。

    Mono newTokenMono = WebClient.create().get()
             .uri(buildUri(SecurityAccessConstant.WEB_REQUEST_TO_AUTH_URL+SecurityAccessConstant.REQUEST_REFRESH
                     , new String[]{"refreshToken", token}))
             .retrieve()
             .bodyToMono(ResultData.class);
    
    AtomicBoolean isPass = new AtomicBoolean(false);
    
    newTokenMono.subscribe(resultData -> {
         if(resultData.getCode() == "200"){
             exchange.getRequest().getHeaders().set(SecurityAccessConstant.HEADER_NAME_TOKEN,
                     SecurityAccessConstant.TOKEN_PREFIX + resultData.getData());
             isPass.set(true);
         }
    }).dispose();
    
    if(isPass.get()){
         return chain.filter(exchange.mutate().request().build());
    }
    

    3. 怎么选择

    3.1 服务端实现的好处

    • 安全性更高: token刷新逻辑在服务端,敏感信息不暴露给客户端。
    • 客户端更轻量: 无需关心刷新逻辑,减少了维护成本。
    • 集中管理: 所有用户的刷新统一在服务端处理,方便调整。
    • 解决一致性问题: 避免多客户端间的token状态不一致。

    3.2 客户端实现的好处

    • 即时性好: 客户端实时监控token有效性,触发刷新,保证操作流畅。
    • 离线支持: 适合需要离线访问或长时间不与服务端通信的场景。
    • 更灵活: 可以根据用户行为动态调整刷新策略。
    • 分担服务端压力: 大量用户同时刷新时,压力分散到客户端。

    不同场景下,选择也不同。在高精度、高安全要求的系统中,服务端刷新更稳妥;而在移动端或简单Web应用中,客户端方案能有效分担服务器压力。具体选哪个,还得看实际需求。

    本站声明:本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系bd@zhengruan.com
    作者最新文章
    编程开发
    相关文章 更多
    精品专题 更多
    装机必备

    正软商城装机必备专区,精选办公、浏览器、安全防护、影音播放、压缩解压、设计创作和系统工具等电脑常用正版软件,帮助用户快速完成新电脑软件配置。

    Windows

    正软商城Windows软件专区,汇集适用于Windows电脑的办公、设计、安全防护、影音播放、开发工具和系统优化软件,提供软件介绍、系统要求、正版授权及购买下载服务。

    PDF教程

    正软商城PDF教程频道提供PDF编辑、转换、合并、拆分、压缩及格式处理方法,同时介绍常用PDF软件和工具的使用技巧。

    macOS软件

    正软商城macOS软件专区,精选适用于Mac电脑的办公、设计、影音、效率、开发和系统工具,提供软件功能介绍、macOS兼容版本、正版授权及购买下载服务。

    IOS软件

    正软商城iOS软件专区,精选适用于iPhone和iPad的办公、学习、影音、设计、效率及AI应用,提供功能介绍、适用设备、系统要求和正版获取方式等信息。

    AI

    正软商城AI软件专区,汇集AI写作、AI绘画、AI视频、AI办公、AI编程、AI翻译、智能客服和数据分析等人工智能工具,提供功能介绍、适用平台、收费方式及正版购买信息。

    Mac软件 更多
    Aperty macOS版
    Aperty macOS版

    Aperty 是 Skylum 推出的 AI 人像修图软件,支持自然肤质修饰、瑕疵处理、面部与身体调整、预设及批量同步。

    火山引擎 网页版
    火山引擎 网页版

    火山引擎是字节跳动旗下企业级云与AI服务平台。聚焦大模型核心能力,依托字节自研豆包多模态大模型,凭借火山方舟一站式平台,提供模型精调、推理部署、智能应用搭建等全链路服务,结合高性能AI算力,助力各行业快速落地大模型数字化与智能化应用。

    立刻MV 网页版
    立刻MV 网页版

    立刻MV 面向音乐人、内容创作者和品牌团队,帮助用户低成本完成歌曲 MV、歌词视频、AI 分镜、图生视频与最终合成。

    蛙蛙写作 网页版
    蛙蛙写作 网页版

    蛙蛙写作 AI 创作工具,覆盖小说、剧本、文案,一键生成文稿,打通文字转视频全流程,高效解决创作难题。

    讯飞智作 网页版
    讯飞智作 网页版

    讯飞智作是一款基于语音合成技术、虚拟人驱动技术与数字人形象声音构建能力的 AI 数字人音视频创作平台。

    超级简历WonderCV 网页版
    超级简历WonderCV 网页版

    超级简历WonderCV是面向应届生和AI小白的智能求职平台,覆盖简历、面试与职业规划。

    音述AI 网页版
    音述AI 网页版

    音述AI,是一个以“用声音述说故事”为核心的 AI 音乐创作与声音分享社区。

    AionClaw macOS版
    AionClaw macOS版

    AionClaw 是可在桌面端运行的任务型 AI 助手,通过数字专家与技能市场组合办公、创作、数据和开发能力。

    Loomy macOS版
    Loomy macOS版

    Loomy 是科大讯飞推出的桌面 AI 助手,可通过对话、任务与工作目录协助处理办公、内容创作和资料整理。

    AirBuddy 3 macOS版
    AirBuddy 3 macOS版

    AirBuddy 3 是面向 macOS 的蓝牙外设增强工具,可集中查看 AirPods、Beats 与苹果设备电量,提供快速连接、播放控制、电量提醒、桌面小组件和多台 Mac 外设接力等功能。

    AirServer macOS版
    AirServer macOS版

    AirServer macOS版可把 Mac 转换为无线投屏接收端,通过 AirPlay 或 Google Cast 接收手机、平板和电脑画面,并支持多设备同时连接、高清镜像及会话录制,适合家庭娱乐、课堂教学、会议演示和应用展示。

    AnyDesk macOS版
    AnyDesk macOS版

    AnyDesk macOS版是一款适用于远程办公、设备维护和客户支持的远程桌面工具,可连接并操作异地计算机,支持文件传输、无人值守访问、会话权限控制及跨平台协作。

    WINDOWS 更多
    讯飞绘文 网页版
    讯飞绘文 网页版

    讯飞绘文网页版是科大讯飞推出的AI图文创作与内容运营工具,覆盖热点选题、文章生成、稿件校对、智能配图、排版发布和数据复盘等环节,适合自媒体创作者及内容团队在线使用。

    Maya(Autodesk Maya) Windows版
    Maya(Autodesk Maya) Windows版

    Autodesk Maya 是面向影视、电视、动画和游戏行业的专业三维制作软件,集多边形与 NURBS 建模、角色绑定、关键帧和动作捕捉动画、Bifrost 特效模拟、材质开发及 Arnold 渲染于一体,

    Aperty Windows版
    Aperty Windows版

    Aperty 是 Skylum 推出的 AI 人像修图软件,支持自然肤质修饰、瑕疵处理、面部与身体调整、预设及批量同步。

    火山引擎 网页版
    火山引擎 网页版

    火山引擎是字节跳动旗下企业级云与AI服务平台。聚焦大模型核心能力,依托字节自研豆包多模态大模型,凭借火山方舟一站式平台,提供模型精调、推理部署、智能应用搭建等全链路服务,结合高性能AI算力,助力各行业快速落地大模型数字化与智能化应用。

    立刻MV 网页版
    立刻MV 网页版

    立刻MV 面向音乐人、内容创作者和品牌团队,帮助用户低成本完成歌曲 MV、歌词视频、AI 分镜、图生视频与最终合成。

    蛙蛙写作 网页版
    蛙蛙写作 网页版

    蛙蛙写作 AI 创作工具,覆盖小说、剧本、文案,一键生成文稿,打通文字转视频全流程,高效解决创作难题。

    讯飞智作 网页版
    讯飞智作 网页版

    讯飞智作是一款基于语音合成技术、虚拟人驱动技术与数字人形象声音构建能力的 AI 数字人音视频创作平台。

    超级简历WonderCV 网页版
    超级简历WonderCV 网页版

    超级简历WonderCV是面向应届生和AI小白的智能求职平台,覆盖简历、面试与职业规划。

    音述AI 网页版
    音述AI 网页版

    音述AI,是一个以“用声音述说故事”为核心的 AI 音乐创作与声音分享社区。

    AionClaw Windows版
    AionClaw Windows版

    AionClaw 是可在桌面端运行的任务型 AI 助手,通过数字专家与技能市场组合办公、创作、数据和开发能力。

    Loomy Windows版
    Loomy Windows版

    Loomy 是科大讯飞推出的桌面 AI 助手,可通过对话、任务与工作目录协助处理办公、内容创作和资料整理。

    AirServer Windows版
    AirServer Windows版

    AirServer Windows版可把电脑变成AirPlay、Google Cast和Miracast无线投屏接收端,适合会议演示、课堂教学及家庭大屏分享。发送设备通常无需另装客户端,并可同时接收和智能排列多个镜像会话。