Flutter3引用原生播放器-IOS(Swift)篇

news2024/11/27 18:37:39

前言

由于Flutter项目中需要使用到播放器功能,因此对flutter中各种播放器解决方案进行了一番研究和比对,最后决定还是自己通过Plugin的方法去引用原生播放器符合自己的需求,本篇文章会对各种解决方案做一个简单的比较,以及讲解一下发Flutter3.0中ios引用原生view的步骤和逻辑,方便大家遇到相同问题时,可以进行一个参考。

video_player

video_player:flutter官方出品。

优点:集成速度快,易上手,使用简单,同时支持Android,ios,web;
缺点:ui丑,功能简单,可定制化差,不支持rtmp等协议直播;
适用:对播放器要求不高,不需要直播,只要视频能播放出来就可以的用户;

better_player

better_player:国外大神在video_player的基础之上二次开发而来,对video_player进行了各种优化,并添加了非常多的实用功能。

优点:同video_player,且比video_player更强大一点;
缺点:依然是可定制化差,不支持rtmp等直播格式;
适用:对视频播放器稍微有要求比如视频格式,播放速度,缓存等功能,又不想自己动手去写原生插件,对ui定制化要求也不高的用户;

fijkplayer

fijkplayer:基于ijkplayer,是对 ijkplayer 的 Flutter 封装,支持安卓和ios;

优缺点:做过安卓和ios原生的,对ijk应该都非常熟悉了,这里就不做过多说明了;
fijkplayer支持各种视频格式包括rtmp等协议直播,支持各种常用功能,支持定制化UI,具体可以去它的官网查看文档说明;
适用:对播放器要求稍高,需要简单定制化播放器ui,需要支持直播的用户;

这里说一些个人的使用体验,因为我的项目需要支持rtmp直播,并且对ui定制化要求较高,所以放弃了官方的video_player,优先选择了fijkplayer,但实际体验上不太尽如人意。虽然支持ui定制,但想要达到自己产品的ui设计,还需要费上很大一番功夫。并且该项目作者有一年多未更新维护了,后续是否会继续更新维护解决bug不得而知。另外,我在播放rtmp时播放失败,不知何故,不过我并没深究原因,因为此时,我已经动了自己动手引用双端原生播放器的念头了!

IOS制作并引用原生View步骤

1)使用Xcode运行项目:

双击flutter项目/ios目录下的Runner.xcworkspace,Xcode会自动打开项目;

2)检验项目:

直接运行,查看是否有报错信息;

如果你已经在pubspec.yaml中使用了大量的第三方插件,此时运行可能会报错:xxx Module not Found!那么你需要在打开终端并cd到ios目录,执行 pod install

在这里插入图片描述
一般情况下都可以解决问题!

3)创建VideoViewPlugin实现FlutterPlugin协议:

import Flutter
import UIKit

class VideoViewPlugin:  NSObject, FlutterPlugin {
    
    @objc static func register(with registrar: FlutterPluginRegistrar) {
        registrar.register(VideoViewFactory(registrar: registrar), withId: "plugins.my_video_player/view")
  }
    
}

由于FlutterPlugin是OC写的,所以在Swift中实现OC协议,前面需要加上NSObject。通过FlutterPluginRegistrar的registrar注册PlatformViewFactory。

plugins.my_video_player/view即为该插件的id,在flutter中引用原生view时需要写入并且安卓,ios和flutter三方都要保持一致!

4)创建VideoViewFactory实现FlutterPlatformViewFactory协议:

import UIKit
import Flutter

class VideoViewFactory:NSObject, FlutterPlatformViewFactory {
    
     private var registrar:FlutterPluginRegistrar
    
     init(registrar: FlutterPluginRegistrar) {
        self.registrar=registrar
        super.init()
    }
    
    //create方法是在flutter中该widget加载显示出来时才执行
    //所以自定flutter中使用原生View时传递的参数在create执行后,且获取到参数后,再创建channel
    func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any?) -> FlutterPlatformView {
        let id=args as! Int
        return VideoViewPlayer(id: id, registrar: registrar)
    }

    //这个方法一定要写,否则接受不到flutter的传参
    func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
        return FlutterStandardMessageCodec.sharedInstance()
    }
}

注意,createArgsCodec()一定要重写,否则无法接收到flutter在使用view时传过来的参数;create方法中的arguments即为传递的参数,create方法返回PlatformView(UIView);

5)创建VideoViewPlayer实现FlutterPlatformView协议:

import UIKit
import Flutter

class VideoViewPlayer: NSObject,FlutterPlatformView {
    //懒加载
    private var videoView:UIView={
        let videoView=UIView()
        return videoView
    }()

    //主要就是在这里,返回原生view
    func view() -> UIView {
        return videoView
    }

}

6)在AppDelegate.swift中注册插件:

import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    GeneratedPluginRegistrant.register(with: self)
    
    //插件名自定义
    if let register=self.registrar(forPlugin: "VideoViewPlugin"){
        VideoViewPlugin.register(with: register)
    }
    
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

这里其实需要两步:

let register=self.registrar(forPlugin: "VideoViewPlugin")
VideoViewPlugin.register(with: register)

注意registrar和register是不一样的!

7)在flutter中引用VideoView:

  • 自定义Widget,MyVideoPlayer:
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class MyVideoPlayer extends StatefulWidget {
  final int id;

  const MyVideoPlayer({
    super.key,
    required this.id,
  });

  
  State<StatefulWidget> createState() => _VideoPlayerState();
}

class _VideoPlayerState extends State<MyVideoPlayer> {
  
  void initState() {
    super.initState();
  }

  
  Widget build(BuildContext context) {
    if (defaultTargetPlatform == TargetPlatform.android) {
      return AndroidView(
        viewType: 'plugins.my_video_player/view',
        creationParams: widget.id, //传递到原生插件的参数(任意类型)
        creationParamsCodec: const StandardMessageCodec(),
      );
    } else {
      return UiKitView(
        viewType: 'plugins.my_video_player/view',
        creationParams: widget.id, //传递到原生插件的参数(任意类型)
        creationParamsCodec: const StandardMessageCodec(),
      );
    }
  }

}

viewType即上面VideoViewPlugin类中注册的插件id:plugins.my_video_player/view,安卓,ios和flutter保持一致!

  • 引用MyVideoPlayer:
SizedBox(
    height: 200,
    child: MyVideoPlayer(id: 0)
  )

这里可以看到,我给原生View传了一个id(需要唯一,我用的是时间戳)的参数,这个是为了后面在创建MethodChannel时加以区分,在同时使用了多个MyVideoPlayer时,不会相互影响!

此时我们就可以来测试一下,引用是否成功了,为了效果明显,我们可以在VideoViewPlayer.swift中为UIView添加一个背景色:

 private var videoView:UIView={
      let videoView=UIView()
      videoView.backgroundColor=UIColor.red
      return videoView
 }()

运行效果:

在这里插入图片描述

如上图,说明原生View已经引用成功了!不过,本篇文章的目的,不只是讲解如何引用原生View,我们的目标是,如何引用原生播放器!

如果你本身是ios开发者,或者开发过ios项目,并且在所开发的项目中使用过第三方的播放器,那么你其实可以直接将你所使用的播放器库,引入到本项目中直接使用,无非就是在VideoViewPlayer.swift中初始化并配置一些参数,最后封装进返回的videoView中!

如果你未开发过ios项目,或者没使用过第三方的播放器,那么你就可以在github上自行搜索star数量高的ios开源播放器项目了。由于我使用的是Swift语言,所以我优先查找了用Swift写的播放器,但查找后发现star量都太低了,而且还必须要支持rtmp播放,最后还是选择了OC写的star量最高的ZFPlayer!

引入开源播放器:ZFPlayer

1)安装:Podfile中添加:

  pod 'ZFPlayer', '~> 4.0'
  pod 'ZFPlayer/ControlView', '~> 4.0'
  pod 'ZFPlayer/AVPlayer', '~> 4.0'
  pod 'ZFPlayer/ijkplayer', '~> 4.0'

由于AVPlayer本身不支持直播,所以还需要引入ZFPlayer/ijkplayer,来支持直播功能!
执行pod intall!由于github总是间歇性无法访问,如果提示SSL超时等问题,可以多试几遍!

2)参考ZFPlayer文档,将ZFPlayer封装进UIView(viewPlayer)返回给Flutter:

VideoViewPlayer中:

    //zfplayer控制器
    private var player:ZFPlayerController = ZFPlayerController()
    //播放器进度控制条UIView
    private var playerControlView=ZFPlayerControlView()
    //AVPlayer管理器
    private var avPlayerManager:ZFAVPlayerManager?
    //IjkPlayer管理器
    private var ijkPlayerManager:ZFIJKPlayerManager?

    init(id:Int,registrar:FlutterPluginRegistrar) {
       super.init()
       //为player设置进度控制条
       player.controlView=playerControlView
       //为player设置containerView
       player.containerView=videoView
   }

此时其实就可以播放视频了,传入视频Url:

//可以根据视频类型,选择使用AVPlayer还是IjkPlayer播放:
//我这里点播使用AVPlayer,直播使用IjkPlayer,自己可以根据自身项目情况选择
if url.starts(with: "http"){
     avPlayerManager=ZFAVPlayerManager()
     avPlayerManager!.assetURL=URL(string: url)
     player.currentPlayerManager=avPlayerManager!

     playerControlView.portraitControlView.slider.isHidden=false
     playerControlView.portraitControlView.currentTimeLabel.isHidden=false
     playerControlView.portraitControlView.totalTimeLabel.isHidden=false
 }
 if url.starts(with: "rtmp"){
     ijkPlayerManager=ZFIJKPlayerManager()
     ijkPlayerManager!.assetURL=URL(string: url)
     player.currentPlayerManager=ijkPlayerManager!

     playerControlView.portraitControlView.slider.isHidden=true
     playerControlView.portraitControlView.currentTimeLabel.isHidden=true
     playerControlView.portraitControlView.totalTimeLabel.isHidden=true
 }
 if avPlayerManager != nil {
     avPlayerManager!.play()
 }
 if ijkPlayerManager != nil {
     ijkPlayerManager!.play()
 }

但这样,无法动态从flutter传入url啊?所以,我们还需要flutter和ios通信,即使用MethodChannel!

3)IOS端创建FlutterMethodChannel:

    //这里就用到了从flutter传入的id
    func initMethodChannel(id:Int,registrar:FlutterPluginRegistrar) {
        let channel = FlutterMethodChannel(name: "my_video_player_\(id)", binaryMessenger: registrar.messenger())
        channel.setMethodCallHandler(handleMethod)
    }

    //接收flutter发来的消息
    func handleMethod(call: FlutterMethodCall, result: FlutterResult){
        switch call.method {
            case "setUrl":
                let url: String=call.arguments as! String
                initPlayer(url: url)
                break
            case "start":
                play()
                break
            case "pause":
                pause()
                break
            case "release":
                stop()
                break
            default:
                result(FlutterMethodNotImplemented)
                break
        }
    }

VideoViewPlayer完整代码如下:

import UIKit
import Flutter
import ZFPlayer

class VideoViewPlayer: NSObject,FlutterPlatformView {
    
    private var videoView:UIView={
        let videoView=UIView()
        return videoView
    }()

    private var player:ZFPlayerController = ZFPlayerController()
    private var playerControlView=ZFPlayerControlView()
    private var avPlayerManager:ZFAVPlayerManager?
    private var ijkPlayerManager:ZFIJKPlayerManager?

     init(id:Int,registrar:FlutterPluginRegistrar) {
        super.init()
        player.controlView=playerControlView
        initMethodChannel(id: id, registrar: registrar)
    }
    
    func view() -> UIView {
        return videoView
    }

}


// MARK:- 播放器初始化及控制
extension VideoViewPlayer{

    //初始化播放器
    func initPlayer(url:String){
        player.containerView=videoView
        if avPlayerManager != nil {
            avPlayerManager!.pause()
            avPlayerManager=nil
        }
        if ijkPlayerManager != nil {
            ijkPlayerManager!.pause()
            ijkPlayerManager=nil
        }
        if url.starts(with: "http"){
            avPlayerManager=ZFAVPlayerManager()
            avPlayerManager!.assetURL=URL(string: url)
            player.currentPlayerManager=avPlayerManager!

            playerControlView.portraitControlView.slider.isHidden=false
            playerControlView.portraitControlView.currentTimeLabel.isHidden=false
            playerControlView.portraitControlView.totalTimeLabel.isHidden=false
        }
        if url.starts(with: "rtmp"){
            ijkPlayerManager=ZFIJKPlayerManager()
            ijkPlayerManager!.assetURL=URL(string: url)
            player.currentPlayerManager=ijkPlayerManager!

            playerControlView.portraitControlView.slider.isHidden=true
            playerControlView.portraitControlView.currentTimeLabel.isHidden=true
            playerControlView.portraitControlView.totalTimeLabel.isHidden=true
        }
    }

    func play(){
        if avPlayerManager != nil {
            avPlayerManager!.play()
        }
        if ijkPlayerManager != nil {
            ijkPlayerManager!.play()
        }
    }

    func pause(){
        if avPlayerManager != nil {
            avPlayerManager!.pause()
        }
        if ijkPlayerManager != nil {
            ijkPlayerManager!.pause()
        }
    }

    func stop(){
        pause()
        player.stop()
    }
}

// MARK:- flutter消息通道处理
extension VideoViewPlayer{

    func initMethodChannel(id:Int,registrar:FlutterPluginRegistrar) {
        let channel = FlutterMethodChannel(name: "my_video_player_\(id)", binaryMessenger: registrar.messenger())
        channel.setMethodCallHandler(handleMethod)
    }

    //接收flutter发来的消息
    func handleMethod(call: FlutterMethodCall, result: FlutterResult){
        switch call.method {
            case "setUrl":
                let url: String=call.arguments as! String
                initPlayer(url: url)
                break
            case "start":
                play()
                break
            case "pause":
                pause()
                break
            case "release":
                stop()
                break
            default:
                result(FlutterMethodNotImplemented)
                break
        }
    }
}

4)flutter端创建MethodChannel:

  init() {
    methodChannel = MethodChannel('my_video_player_$id');
    methodChannel.setMethodCallHandler((call) => flutterMethod(call));
  }

  Future<void> setUrl(String url) async {
    return methodChannel.invokeMethod('setUrl', url);
  }
  
  Future<void> start() async {
    return methodChannel.invokeMethod('start');
  }

  ...

flutter中调用setUrl后,再调用start方法即可播放了!

我这里给flutter端的MethodChanel封装为了一个Controller:

import 'package:flutter/services.dart';
import 'package:kjjl_flutter/components/loading.dart';
import 'package:kjjl_flutter/utils/log_util.dart';

class VideoViewController {
  late MethodChannel methodChannel;

  int id;

  VideoViewController({required this.id});

  init() {
    methodChannel = MethodChannel('my_video_player_$id');
    methodChannel.setMethodCallHandler((call) => flutterMethod(call));
  }

  Future<void> setUrl(String url) async {
    return methodChannel.invokeMethod('setUrl', url);
  }

  Future<void> start() async {
    return methodChannel.invokeMethod('start');
  }

  Future<void> pause() async {
    return methodChannel.invokeMethod('pause');
  }

  Future<void> release() async {
    return methodChannel.invokeMethod('release');
  }

  Future<void> stopFullScreen() async {
    return methodChannel.invokeMethod('stopFullScreen');
  }

  Future<dynamic> flutterMethod(MethodCall methodCall) async {
    switch (methodCall.method) {

    }
  }
}

在State中使用时的部分代码:

SizedBox(
   height: videoHeight,
   child: currentVideo != null && videoViewController != null
       ? MyVideoPlayer(id: videoViewController!.id)
       : SizedBox(),
  )

...

VideoModel? currentVideo;
VideoViewController? videoViewController;

//页面销毁时释放播放器

void dispose() {
  if (videoViewController != null) videoViewController!.release();
  super.dispose();
}

//点击播放时执行
play(VideoModel video) {
    if (videoViewController != null) {
      if (currentVideo != null && currentVideo!.id == video.id) return;
      setState(() {
        currentVideo = video;
        videoViewController!.release();
        startPlay(video);
      });
    } else {
      setState(() {
        currentVideo = video;
        videoViewController = VideoViewController(id: DateTime.now().millisecondsSinceEpoch);
        videoViewController!.init();
        //延时500ms执行,是为了防止播放器还未初始化完成,就调用了播放,导致首次播放失败;
        Future.delayed(const Duration(milliseconds: 500), () {
          startPlay(video);
        });
      });
    }
  }

startPlay(VideoModel video){
  videoViewController!.setUrl(video.mobileUrl);
  videoViewController!.start();
}

效果图:

直播:

在这里插入图片描述

录播:

在这里插入图片描述

下一篇:Flutter3引用原生播放器-Android篇

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/372842.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

STM32—DMA

什么是DMA&#xff1f; DMA(Direct Memory Access&#xff0c;直接存储器访问) 提供在外设与内存、存储器和存储器、外设与外设之间的高速数据传输使用。它允许不同速度的硬件装置来沟通&#xff0c;而不需要依赖于CPU&#xff0c;在这个时间中&#xff0c;CPU对于内存的工作来…

Leetcode 剑指 Offer II 016. 不含重复字符的最长子字符串

题目难度: 中等 原题链接 今天继续更新 Leetcode 的剑指 Offer&#xff08;专项突击版&#xff09;系列, 大家在公众号 算法精选 里回复 剑指offer2 就能看到该系列当前连载的所有文章了, 记得关注哦~ 题目描述 给定一个字符串 s &#xff0c;请你找出其中不含有重复字符的最长…

软考之操作系统知识

目录 1.进程管理-进程的概念 2.进程的三态图和五态图 3.进程的同步与互斥 4.PV操作应用 5.死锁问题 6.银行家算法 7.存储管理 8.段式存储组织 9.段页式存储组织 10.页面置换算法 11.磁盘管理 12.作业管理 13.索引文件结构 14.树型目录结构 15.空闲存储空间管理 …

第四届蓝桥杯省赛 C++ B组 - 翻硬币

✍个人博客&#xff1a;https://blog.csdn.net/Newin2020?spm1011.2415.3001.5343 &#x1f4da;专栏地址&#xff1a;蓝桥杯题解集合 &#x1f4dd;原题地址&#xff1a;翻硬币 &#x1f4e3;专栏定位&#xff1a;为想参加蓝桥杯的小伙伴整理常考算法题解&#xff0c;祝大家都…

人工智能学习07--pytorch09--LeNet

参考&#xff1a; 视频&#xff1a; https://www.bilibili.com/video/BV187411T7Ye/?spm_id_from333.999.0.0&vd_sourceb425cf6a88c74ab02b3939ca66be1c0d 博客&#xff1a;https://blog.csdn.net/STATEABC/article/details/123661612?utm_mediumdistribute.pc_feed_404.…

如何使用goquery进行HTML解析以及它的源码分析和实现原理

目录 goquery 是什么 goquery 能用来干什么 goquery quick start 玩转goquery.Find() 查找多个标签 Id 选择器 Class 选择器 属性选择器 子节点选择器 内容过滤器 goquery 源码分析 图解源码 总结 goquery 简介 goquery是一款基于Go语言的HTML解析库&#xff0c;…

聚类算法(上):8个常见的无监督聚类方法介绍和比较

无监督聚类方法的评价指标必须依赖于数据和聚类结果的内在属性&#xff0c;例如聚类的紧凑性和分离性&#xff0c;与外部知识的一致性&#xff0c;以及同一算法不同运行结果的稳定性。 本文将全面概述Scikit-Learn库中用于的聚类技术以及各种评估方法。 本文将分为2个部分&…

【Mac 教程系列】如何在 Mac 中用终端命令行方式打开 Sublime Text ?

如何在 Mac 中用终端命令行方式打开 Sublime Text ? 用 markdown 格式输出答案。 不少于1000字。细分到2级目录。 如何在 Mac 中用终端命令行方式打开 Sublime Text ? 一、首先确保已经安装 Sublime Text 前往官网https://www.sublimetext.com/下载 Sublime Text&#xff0c…

Bootstrap表单的使用

文章目录前言一、创建基础表单垂直表单&#xff08;默认&#xff09;内联表单&#xff08;水平显示&#xff09;表单控制尺寸大小给表单控件添加帮助文本禁用/只读表单无边框的控件取色器选择菜单&#xff08;默认宽度100%&#xff09;调整下拉菜单的大小表单开关滑块表单组多个…

LabVIEW网络服务安全

LabVIEW网络服务安全如何保护Web服务&#xff1f;当许多人考虑安全性时&#xff0c;他们会考虑加密、用户ID和密码。用户ID和密码用于授权&#xff08;告诉目标谁在发出请求&#xff09;。加密保护客户端和服务器之间的通信流量&#xff0c;以便未经授权的个人无法拦截和读取发…

【架构师】零基础到精通——康威定律

博客昵称&#xff1a;架构师Cool 最喜欢的座右铭&#xff1a;一以贯之的努力&#xff0c;不得懈怠的人生。 作者简介&#xff1a;一名Coder&#xff0c;软件设计师/鸿蒙高级工程师认证&#xff0c;在备战高级架构师/系统分析师&#xff0c;欢迎关注小弟&#xff01; 博主小留言…

20_FreeRTOS低功耗模式

目录 低功耗模式简介 STM32低功耗模式 Tickless模式详解 Tickless模式相关配置 实验源码 低功耗模式简介 很多应用场合对于功耗的要求很严格,比如可穿戴低功耗产品、物联网低功耗产品等。 一般MCU都有相应的低功耗模式,裸机开发时可以使用MCU的低功耗模式。 FreeRTOS也…

Linux学习(8.7)命令与文件的搜寻

目录 命令与文件的搜寻 which 文件档名的搜寻&#xff1a; whereis (寻找特定文件) locate find 以下内容转载自鸟哥的Linux私房菜 命令与文件的搜寻 which 这个命令是根据『PATH』这个环境变量所规范的路径&#xff0c;去搜寻『运行档』的档名&#xff5e; 所以&am…

计算机组成原理——运算方法续集(浮点数表示法)

浮点表示法把一个数字的有效数字和数的范围在计算机的一个存储单元中分别予以表示。这种精度分别表示的方法&#xff0c;相当于数的小数点位置随比例因子的不同而在一定范围内可以浮点&#xff0c;所以称为浮点表示法。在计算机中一个任意二进制数N可以写成N 2^e.M其中M称为浮…

Overleaf推广奖励:增加合作者的数量、解锁Dropbox同步和项目修改历史

Overleaf推广奖励 Overleaf是一个LaTeX\LaTeXLATE​X在线编译器&#xff0c;它可以让你与合作者共同在线编辑文档。但是默认的免费账号仅能邀请一个合作者。那么如何增加合作者的数量呢&#xff1f; Overleaf推出了一个奖励计划&#xff0c;你邀请其他人注册Overleaf&#xf…

使用MavenCentral发布Kotlin多平台库的远程依赖(KMM,KMP)

前言 开发者可能都会做自己的开源库,像我以前只做一些单平台的,如Android或JVM平台,这时候直接使用jitpack即可,很简单就能发布远程依赖 jitpack参考: 发布开源库的踩坑经历:jitpack.io_李小白lt的博客 而现在Kotlin可以通过expect来实现原生多平台项目(或库),这时我们开发…

【计算机三级网络技术】 第二篇 中小型系统总体规划与设计

文章目录一、基于网络的信息系统基本结构二、划分网络系统组建工程阶段三、网络需求调研与系统设计原则四、网络用户调查与网络工程需求分析1.网络用户调查2.网络节点的地理位置分布3.应用概要分析4.网络需求详细分析五、网络总体设计基本方法1.网络工程建设总体目标与设计原则…

C++---线性dp---传纸条(每日一道算法2023.2.26)

注意事项&#xff1a; 本题dp思路与 “线性dp–方格取数” 一致&#xff0c;下方思路仅证明为什么使用方格取数的思路是正确的。 题目&#xff1a; 小渊和小轩是好朋友也是同班同学&#xff0c;他们在一起总有谈不完的话题。 一次素质拓展活动中&#xff0c;班上同学安排坐成…

3.7寸按键翻页工牌

产品参数 产品型号 ESL_BWR3.7_BLE 产品尺寸 (mm) 62.51066.5 显示技术 E ink 显示区域 (mm) 47.32(H)81.12(V) 分辨率 (像素) 280480 像素尺寸(mm) 0.1690.169 150dpi 显示颜色 黑/白 视觉角度 180 工作温度 0℃ - 50℃ 电池 500mAh ( Type-C 充电…

黑盒测试用例设计方法-等价类划分法

目录 一、等价类的作用 二、等价类的分类 三、等价类的方法 四、等价类的原则 五、按照测试用例的完整性划分等价类 六、等价类步骤 七、案例 一、等价类的作用 为穷举测试设计测试点。 穷举&#xff1a;列出所有的可能情况&#xff0c;对其一一判断。 测试点&#x…