• 一种子模块化的基于Hash刷新机制的iOS端数据驱动的MVVM架构思考


    前言:

    声明:本文系本人之愚见,所列举的事实为客观存在,并不存在绝对的抨击。如果您的想法和本人有出入欢迎留言共同探讨,有建议欢迎斧正,本人仅是菜鸟一枚,略有不便之处敬请读者海涵。

    iOS端的架构基准无非就是苹果推荐的MVC,经典MVVM,解耦性极好的MVP,新生代的VIPER及Uber推出的Ribs,当然还有臭名昭著的CCC(所有代码都放在ViewController里面)。架构的实施标准无非就是解决软件工程中两个重要问题:如何加快软件开发速度;如何解决软件工程后期维护问题。以CCC为例,这个架构的项目后期维护绝对是令人头疼的问题,甚至可能还会影响新功能的迭代,而MVP解耦性虽然很好,但是逻辑比较复杂。剩下较为流行的架构就剩MVVM了,这个架构除了编写较为麻烦,在后期迭代方面有着较为明显的优势,条理较为清晰。但我接触的一些项目里面的MVVM架构也有较为明显的问题,即ViewModel过于臃肿,虽然ViewController里面很干净,但是ViewModel里面复杂的业务逻辑、胶水代码让其成为了第二个ViewController,仍然不好维护。

    现有MVVM架构引起的思考:

    • 过于臃肿的ViewModel
      ViewModel的本意是解决ViewController过于臃肿的问题,替代一部分ViewController的功能,连接View层及Model层。然而不分场合的滥用,导致ViewModel越来越庞大,后期迭代的时候成为问题。
    • 复杂的逻辑走向和胶水代码
      有些特殊的时间是ViewModel产生不了的,例如系统的回调事件。ViewModel我见过的基本上都是继承自NSObject,这个类自身并没有和系统及application有过多的交集。最明显的例子就是ViewController初始化完毕后会回调viewDidLoad(),一部分特殊的业务需要在这个回调方法里进行操作例如网络请求,然而ViewModel本身没这个方法,也无法hook到ViewController里面的这个回调方法,只有手动写一个viewModelDidLoad()方法进行勾起处理,每个人都有自己的编码风格,久而久之这些特殊的事件就让ViewModel变得越来越复杂。
    • 事件流的传递
      关于事件流的传递在iOS里面比Android是要丰富的,但也造就了五花八门的写法。最简单的就是View层的属性暴露出来成为public的属性,然后给控件addTarget;也可以使用delegate,但要考虑多对一的情况;以及似乎与MVVM绑定的特性及即向绑定,在ios里面体现就是RAC和RxSwift;还可以使用Notification,但是要考虑到通知满天飞找不到头的尴尬场面;最后就是block。
    • 编写较为繁琐
      这个问题一直是MVVM的弊端,从View层,Model层,ViewModel层,ViewController层,这个是个棘手的问题。

    设计思路:

    设计的思路基本上沿着上面的思考而来。

    平常开发居多的是以长页面形式的ViewController及固定空间显示的ViewController居多,这一点可以参考美团饿了吗的点餐、订单详情界面。界面内容太长需要上下滑动。这给了我们一个思路:能不能利用滚动形式的组件去管理这些子业务?答案是可以的,而且也有现成的框架(IGListKit)。那么具体思考起来可不可以这样理解:把界面当成很多UICollectionViewCell组成的大部分,一些相同类型业务的Cell就归纳为一个Section里面,由一个容器去管理(UICollectionView)。这个做法得到不少公司产品的体现,没记错的话字节的“大力自习室”及唯品会就是这个思路。

    那是不是直接重写UICollectionView就行了?我的答案是可以,但是其内部细节不得而知,不如自己实现一个列表流组件,以此为基础进行迭代。

    对比Android开发,其有Activity(类似iOS中的ViewController),Fragment(依附于Activity的碎片显示类),Item(类似UICollectionViewCell),Bundle(特殊的用以保存数据的类),发现把这些拿过来刚好符合条件。
    Activity即当成UICollectionView,Fragment当成UICollectionView中的Section节点,Item直接继承于UIView(Cell内部布局层次较多,这里直接选择一个干净的UIView)。

    目前一个较为流行的东西叫做“数据驱动”,即被刷新对象本身没有直接刷新,而是通过其他类使用指定方法和参数间接刷新。那么这个参数就可以理解为上文的Bundle。这里将Bundle设计为不仅仅是数据储存类(典型的Model),它还可以保存控件的frame,block变量用于事件处理。这里也就是上面思考里面提到的事件流的处理,使用block的优点很明显,类似于Android中的匿名类监听器,对接双方是一对一的,不存在多对一情况,可以很快的从View层找到逻辑处理的地方,除了循环引用需要注意,还要多写block,我认为这是目前为止较好的处理方式。

    复杂的逻辑走向怎么处理?由于iOS的封闭性我们不知道系统内部实现细节,拿不到具体的context,所以这里用一个较为粗鲁的方式处理:即 ViewController手动调用activity的声明的方法,activity再手动调用fragment的声明方法。虽然很浅显,但是实际效果很好,常见的如viewDidLoad()viewWillAppear()viewDidAppear()等都能捕捉到。虽然方式简单粗暴,但是很有效,而且统一了这些回调方法的名称,较为好看吧。

    至于编写较为繁琐的问题,本人思考了不少时间实在没找到如何在开发速度和后期可维护性方面都做到最好,只能选择可维护性。如果有大佬有什么较好的处理方式欢迎留言共同探讨。

    问题处理:

    • 如何解决ViewModel过于臃肿的问题?
      这里的解决思路是把初始化需要的步骤放到具体的View子项里面。例如,每个被划分后的Item通过数据驱动协议将bundle进行转型赋值,block的处理等。这样就减轻了ViewModel内初始化View层的问题。

    • 如何解决刷新的问题?进一步衍生为如何解决局部刷新的问题?
      Item设置一个bundle属性,这个属性就是用以刷新Item的关键。由于NSObject有个hash属性,可以通过hash判断bundle的唯一性,如果一开始的item的bundle的hash与待刷新操作的相同则认定是同一个hash无需刷新,如果是不同值和认定其bundle被更新了需要刷新,如果没有值则认item是第一次执行刷新操作,需要调用数据驱动方法。另外bundle的hash值需要一个特殊的队列进行存储,以保证其hash队列长度最大为2。这也就是标题里面的hash刷新机制由来。与IGList不同的是,iglist实现了diff算法进行增上改,这是考虑cell复用的情况,本文不考虑复用情况所以也不需要diff算法(实际上数据流展示页面diif才会用到)。

    • 如何解决适配横竖屏问题?
      这里解决的途径是ViewController里重写这个方法:

        override func willRotate(to toInterfaceOrientation: UIInterfaceOrientation, duration: TimeInterval) {
            super.willRotate(to: toInterfaceOrientation, duration: duration)
            activity.activityRotate(rawValue: toInterfaceOrientation.rawValue)
        }
    
    • 1
    • 2
    • 3
    • 4

    可以看出来这是手动调用rotate方法,然后传递到item层,重写rotate方法进行横屏、竖屏的frame处理。

    最终工程:

    最终本文实现以下特点:

    • ViewControllerActivity-Fragment-Item-Bundle-Model的层级划分。
    • 数据驱动内容
    • Hash刷新
    • 屏幕旋转适配
    • 宿主生命周期
    • Fragment级别的增删。

    具体工程:
    在这里插入图片描述
    其中TextFragment和BlankFragment是便利实现仅有文字或空白部分Fragment实现的类,很便捷。
    具体代码可移步至github观看

    以登陆为例的ViewController编写:

    即然文字分析了许多不如直接写一个实例看看效果如何,以登陆为例,只要账号为admin密码为123456就判断登陆成功。

    分析:

    ActivityModel即为ViewModel,Item为View层,Handler层暂未写。

    在这里插入图片描述

    • ViewController层:

    LoginDemoViewController.swift

    import UIKit
    
    class LoginDemoViewController: UIViewController{
        
        var naviBar: SGNavigationBar!
        var activity: SGActivity!
        var activityModel: LoginActivityModel!
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            // Do any additional setup after loading the view.
            
            initView()
            
        }
        
        override func viewWillAppear(_ animated: Bool) {
            super.viewWillAppear(animated)
            self.activity.activityWillAppear()
        }
        
        override func viewWillDisappear(_ animated: Bool) {
            super.viewWillDisappear(animated)
            self.activity.activityWillDisappear()
        }
        
        override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
            self.activity.activityDidAppear()
        }
        
        override func viewDidDisappear(_ animated: Bool) {
            super.viewDidDisappear(animated)
            self.activity.activityDidDisappear()
        }
        
        override func willRotate(to toInterfaceOrientation: UIInterfaceOrientation, duration: TimeInterval) {
            super.willRotate(to: toInterfaceOrientation, duration: duration)
            activity.activityRotate(rawValue: toInterfaceOrientation.rawValue)
        }
        
        private func initView(){
            self.view.backgroundColor = .white
            naviBar = SGNavigationBar(title: "Login", leftText: "")
            naviBar.isEnableDividerLine = true
            naviBar.isBlur = false
            naviBar.setOnLeftClickListener {
                self.dismiss(animated: true)
            }
            self.view.addSubview(naviBar)
            
            activity = SGActivity(frame: CGRect(x: 0,
                                                y: naviBar.frame.maxY,
                                                width: self.view.frame.width,
                                                height: kSCREEN_HEIGHT - naviBar.frame.maxY))
            activityModel = LoginActivityModel()
            activityModel.context = self
            activity.activityDelegate = activityModel
            self.view.addSubview(activity)
            activity.activityDidLoad()
        }
        
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • Item层:
    import Foundation
    import UIKit
    
    class TextFieldItem: SGItem{
        
        public lazy var accountTextFiled:  UITextField = self.createAccountTextField()
        public lazy var passwordTextField: UITextField = self.createPasswordTextField()
        private var textFiledBundle: TextFieldBundle?
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            
            initView()
        }
        
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        
        override func bindBundle(_ bundle: Any?) {
            self.textFiledBundle = bundle as? TextFieldBundle
            
        }
        
        override func bindBundleLandscape(_ bundle: Any?) {
            
        }
        
        override func itemWillRotate(rawValue: Int) {
            switch rawValue{
            case 1:
                accountTextFiled.frame = CGRect(x: 16,
                                                y: 5,
                                                width: kSCREEN_WIDTH - 16 * 2,
                                                height: 30)
                passwordTextField.frame = CGRect(x: 16,
                                                 y: accountTextFiled.frame.maxY + 10,
                                                 width: kSCREEN_WIDTH - 16 * 2,
                                                 height: 30)
            case 3:
                accountTextFiled.frame = CGRect(x: 16,
                                                y: 3,
                                                width: kSCREEN_HEIGHT - 16 * 2,
                                                height: 30)
                passwordTextField.frame = CGRect(x: 16,
                                                 y: accountTextFiled.frame.maxY + 3,
                                                 width: kSCREEN_HEIGHT - 16 * 2,
                                                 height: 30)
            case 4:
                accountTextFiled.frame = CGRect(x: 16,
                                                y: 3,
                                                width: kSCREEN_HEIGHT - 16 * 2,
                                                height: 30)
                passwordTextField.frame = CGRect(x: 16,
                                                 y: accountTextFiled.frame.maxY + 3,
                                                 width: kSCREEN_HEIGHT - 16 * 2,
                                                 height: 30)
            default:
                break
            }
        }
        
    }
    
    extension TextFieldItem{
        
        private func initView(){
            self.addSubview(accountTextFiled)
            self.addSubview(passwordTextField)
        }
        
        private func createAccountTextField() -> UITextField{
            let textFiled = UITextField()
            textFiled.borderStyle = .roundedRect
            textFiled.frame = CGRect(x: 16, y: 5, width: kSCREEN_WIDTH - 16 * 2, height: 30)
            textFiled.placeholder = "Input account please."
            return textFiled
        }
        
        private func createPasswordTextField() -> UITextField{
            let textFiled = UITextField()
            textFiled.borderStyle = .roundedRect
            textFiled.frame = CGRect(x: 16, y: accountTextFiled.frame.maxY + 10, width: kSCREEN_WIDTH - 16 * 2, height: 30)
            textFiled.placeholder = "Input password please."
            return textFiled
        }
        
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88

    ButtonItem.swift

    import UIKit
    
    class ButtonItem: SGItem{
        
        private lazy var registerButton:  UIButton = self.createRegisterButton()
        private lazy var loginButton : UIButton = self.createLoginButton()
        private var buttonBundle: ButtonBundle?
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            
            initView()
        }
        
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        
        override func bindBundle(_ bundle: Any?) {
            self.buttonBundle = bundle as? ButtonBundle
            
        }
        
        override func bindBundleLandscape(_ bundle: Any?) {
            
        }
        
        override func itemWillRotate(rawValue: Int) {
            switch rawValue{
            case 1:
                registerButton.frame = CGRect(x: 50, y: 5, width: 60, height: 26)
                loginButton.frame = CGRect(x: kSCREEN_WIDTH - 50 - 60, y: 5, width: 60, height: 26)
            case 3:
                registerButton.frame = CGRect(x: 50, y: 5, width: 60, height: 26)
                loginButton.frame = CGRect(x: kSCREEN_HEIGHT - 50 - 60, y: 5, width: 60, height: 26)
            case 4:
                registerButton.frame = CGRect(x: 50, y: 5, width: 60, height: 26)
                loginButton.frame = CGRect(x: kSCREEN_HEIGHT - 50 - 60, y: 5, width: 60, height: 26)
            default:
                break
            }
        }
        
    }
    
    extension ButtonItem{
        
        private func initView(){
            self.addSubview(registerButton)
            self.addSubview(loginButton)
        }
        
        private func createRegisterButton() -> UIButton{
            let button = UIButton(type: .system)
            button.frame = CGRect(x: 50, y: 5, width: 60, height: 26)
            button.setTitle("Register", for: .normal)
            button.setOnClickListener { v in
                if self.buttonBundle?.registerClosure != nil{
                    self.buttonBundle?.registerClosure!()
                }
            }
            return button
        }
        
        private func createLoginButton() -> UIButton{
            let button = UIButton(type: .system)
            button.frame = CGRect(x: kSCREEN_WIDTH - 50 - 60, y: 5, width: 60, height: 26)
            button.setTitle("Login", for: .normal)
            button.setOnClickListener { v in
                if self.buttonBundle?.loginClosure != nil{
                    self.buttonBundle?.loginClosure!()
                }
            }
            return button
        }
        
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • Fragment层
      TextFieldFragment.swift
    import UIKit
    
    class TextFieldFragment: SGFragment, SGFragmentDelegate{
        
        lazy var textFieldBundle: TextFieldBundle = {
            return TextFieldBundle()
        }()
        
        lazy var textFieldItem: TextFieldItem = {
            let item = TextFieldItem()
            item.bundle = textFieldBundle
            item.size = CGSize(width: kSCREEN_WIDTH, height: 130)
            return item
        }()
        
        override init() {
            super.init()
            
            self.items.append(textFieldItem)
            
            self.delegate = self
        }
        
        func numberOfItemForFragment(_ fragment: SGFragment) -> Int {
            return items.count
        }
        
        func itemAtIndex(_ index: Int, fragment: SGFragment) -> SGItem {
            return items[index]
        }
        
    }
    
    extension TextFieldFragment{
       
        public func getAcccountText() -> String?{
            return textFieldItem.accountTextFiled.text ?? ""
        }
        
        public func getPasswordText() -> String?{
            return textFieldItem.passwordTextField.text ?? ""
        }
        
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45

    ButtonFragment.swift

    import UIKit
    
    class ButtonFragment: SGFragment, SGFragmentDelegate{
        
        private lazy var buttonBundle: ButtonBundle = {
            let bundle = ButtonBundle()
            bundle.registerClosure = { [weak self] in
                if self?.registerClosure != nil{
                    self?.registerClosure!()
                }
            }
            bundle.loginClosure = { [weak self] in
                if self?.loginClosure != nil{
                    self?.loginClosure!()
                }
            }
            return bundle
        }()
        
        private lazy var buttonItem: ButtonItem = {
            let item = ButtonItem()
            item.bundle = buttonBundle
            item.size = CGSize(width: kSCREEN_WIDTH, height: 100)
            return item
        }()
        
        public var loginClosure: (() -> Void)?
        public var registerClosure: (() -> Void)?
        
        override init() {
            super.init()
            
            self.items.append(buttonItem)
            
            self.delegate = self
        }
        
        func numberOfItemForFragment(_ fragment: SGFragment) -> Int {
            return items.count
        }
        
        func itemAtIndex(_ index: Int, fragment: SGFragment) -> SGItem {
            return items[index]
        }
        
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • Bundle层:
      ButtonBundle.swift
    import UIKit
    
    class ButtonBundle: NSObject{
        
        public var loginClosure: (() -> Void)?
        public var registerClosure: (() -> Void)?
        
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • ActivityModel层:
      LoginActivityModel.swift
    import UIKit
    import Foundation
    
    class LoginActivityModel: NSObject, SGActivityDelegate{
        
        public weak var context: UIViewController?
        
        private let ACCOUNT: String = "admin"
        private let PASSWORD: String = "123456"
        
        private var fragments: Array<SGFragment>!
        
        private lazy var textFieldFragment: TextFieldFragment = {
            let fragment = TextFieldFragment()
            return fragment
        }()
        
        private lazy var buttonFragment: ButtonFragment = {
            let fragment = ButtonFragment()
            fragment.loginClosure = { [weak self] in
                self?.loginEvent()
            }
            fragment.registerClosure = { [weak self] in
                self?.registerEvent()
            }
            return fragment
        }()
        
        var clickAction: (() -> Void)?
        
        override init() {
            super.init()
            
            initData()
        }
        
        private func initData(){
            fragments = Array<SGFragment>()
            
            let notice = SGTextFragment(text: "Start A Journey")
            let blank1 = SGBlankFragment(height: 80)
            let blank2 = SGBlankFragment(height: 10)
            
            fragments.append(notice)
            fragments.append(blank1)
            fragments.append(textFieldFragment)
            fragments.append(blank2)
            fragments.append(buttonFragment)
            
        }
        
    }
    
    // MARK: - Event
    extension LoginActivityModel{
        
        private func loginEvent(){
            if textFieldFragment.getAcccountText() == ACCOUNT && textFieldFragment.getPasswordText() == PASSWORD {
                context?.toast("Login Succeed.", location: .center)
                Log.debug("Login succeed.")
            }
        }
        
        private func registerEvent(){
            if textFieldFragment.getAcccountText() != ACCOUNT {
                Log.debug("Register Done.")
            }
        }
    }
    
    extension LoginActivityModel{
        
        func numberOfSGFragmentForSGActivity(_ activity: SGActivity) -> Int {
            return fragments.count
        }
    
        func fragmentAtIndex(_ activity: SGActivity, index: Int) -> SGFragment {
            return fragments[index]
        }
        
        func topFragmentForSGActivity(_ activity: SGActivity) -> SGFragment? {
            return SGTextFragment(text: "Swiped to the top")
        }
        
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86

    登陆成果

    竖屏默认状态:
    在这里插入图片描述

    登陆成功:
    在这里插入图片描述

    横屏状态:
    在这里插入图片描述
    可以看到,无论是ViewController层还是ActivityModel层,其耦合性都得到了不少的改善,数据流向清晰简单,事件传递正常,横竖屏也适配正常。

  • 相关阅读:
    HTML5期末大作业:个人生活网站设计——嘉尔明星(7页)带特效带音乐
    提高工作效率,让你快速获得Hypermesh二次开发能力!
    谷歌杀死IE工具栏
    QT第三方库加载pro解读
    selenium.common.exceptions.WebDriverException: Message: ‘chromedriver’ executable needs to be in PAT
    Vuex基础知识
    Python基础知识进阶之正则表达式
    Python学习小组课程P1-Python基础(1)语法与数组
    Simulink 自动代码生成电机控制:Keil工程转到CubeIDE操作(1/2)
    【giszz笔记】产品设计标准流程【8】
  • 原文地址:https://blog.csdn.net/kicinio/article/details/126689051