VBA小白入门之:在Excel中如何将VBA与PowerQuery结合

一、VBA和PowerQuery的优缺点

VBA和PowerQuery都是Excel中内置[1]的编程功能。VBA的优点在于灵活性极强,缺点在于无法进行多线程运算;而PowerQuery的优点在于按照SQL的逻辑进行的设计,因而天然地就支持“多线程”运算(更准确地讲可以视作向量运算)。为何不把二者结合起来?这样可以将开发效率和运行效率同时提高!

二、如何利用VBA操纵PowerQuery

常用的方式是将PowerQuery的查询加载到某个Sheet中的Table/Range(在PowerQuery看来是Table,在VBA看来是Range),然后通过某种方式操纵PowerQuery的刷新动作。下面讲的内容均是如何利用VBA来刷新某个连接到PowerQuery的Table/Range。

1、基本操作

刷新单个Range

Range("Rng1").ListObject.QueryTable.Refresh BackgroundQuery:=False
'Rng是待刷新Range的Name属性

刷新所有Range

ThisWorkbook.RefreshAll

2、更精细的操控——等刷新完毕后执行下一句

在使用ListObject.QueryTable.Refresh时,VBA无法等待某个Range刷新完毕后再执行下一句。

a、粗暴的处理

如果编写的程序比较简单,不需要指定刷新哪几个Range,则可以利用RefreshAll+CalculateUntilAsyncQueriesDone来实现。比如:

ThisWorkBook.RefreshAll
Application.CalculateUntilAsyncQueriesDone
'等待所有Range刷新完后再执行下一句
MsgBox "完成!"

这样,VBA会等待所有Range刷新完后再执行下一句。但是这种用法比较简单粗暴,在实践中遇到更复杂的情况时,就无法派上用场,因此一般不会用它的。

b、精细的处理

通过本人在StackOverflow上查找,发现不仅ListObject.QueryTable.Refresh可以刷新PowerQuery加载到的Range,.OLEDBConnection.Refresh也可以(不明觉厉,哈哈),而且当把它的BackgroundQuery属性设置成False时,可以让当前的刷新完成后,再执行VBA中的下一句。利用这个特性,下面这个sub就可以实现等待刷新的功能:

Sub RefreshSheet(RngName)
'RngName是String,是待刷新的Range的Name属性值
    With ThisWorkbook.Connections("查询 - " & RngName).OLEDBConnection
        .BackgroundQuery = False
        .Refresh
    End With
End Sub

3、性能优化——同时刷新某几张表

当对于性能要求不高的时候,可以循环用上面的RefreshSheet这个Sub,在代码上做到简洁,但是这样就浪费掉了PowerQuery中的一个优秀的功能——异步刷新。所谓异步刷新,就是指充分利用缓存和多线程等机制,使得同时刷新多个Range要远快于分别顺次刷新这些Range

在不使用VBA的时候,最常见的方式就是点击“全部刷新”,但是这样不能指定只刷新某几个Range。而若使用VBA来实现同时只刷新某几个Range的效果,则需要费一定力气。

a、主要原理

将BackgroundQuery设置为True,然后利用Range("Rng1").ListObject.QueryTable.Refresh BackgroundQuery:=True或将OLEDBConnection中的BackgroundQuery设置为True后再.Refresh来启动异步刷新。

b、主要问题

如何等待这些Range刷新完毕,再执行VBA的下一句?这就需要找到可以等Range刷新的VBA命令。遗憾的是,并没有直接等待Range刷新完毕的语句。Application.CalculateUntilAsyncQueriesDone会让VBA卡死,DoEvents或Sleep则会因为二者均可“阻止”PowerQuery将刷新后的表加载至Sheet中,而导致PowerQuery始终无法完成刷新,最终陷入死循环。但是,当我在调试VBA的时候发现,一旦终止VBA语句,则待刷新的Range会立刻加载到Sheet里。也就是说,DoEvents、Sleep只能是在VBA语句里等,而不能在其以外的范围内等。因此要想出一招既等又不等的方式。

c、解决办法

基本思路是,首先找一个生僻字符(比如我找的字是“飝”),令待刷新的Range的.Cells(1,1).Value等于这个生僻字,第二步是开启异步刷新并令VBA结束运行,第三步当生僻字因为PowerQuery的刷新完毕而消失时,利用Workbook_Change来重新触发VBA语句,检测这些表是否均完成了刷新(即生僻字“飝”是否都消失了),第四步是若生僻字都消失了,则执行下一句,否则结束VBA的运行,等待PowerQuery继续刷新。

但是在具落笔时,遇到了一些客观的情况。

功能实现上的有:

  • 怎么让VBA结束运行后记得住哪些表进行了刷新、后续要执行哪个sub?

    创建一个class,然后让这个class在模块内声名为Public,将刷新的表的名称、后续执行的sub的名称作为该class的一个属性装进去。

  • 怎么让VBA去执行下一个sub?

    利用Application.Run,尽管它有一些不方便。

性能优化上的有:

  • 如何减少Workbook_Change事件触发带来的运算量?

    在上述创建的class中,加一个属性,表示目前异步刷新的状态,如果不在进行异步刷新的话,则结束Worksheet_Change这个sub。

  • 如何减少异步刷新的内存及CPU占用,从而进一步强化性能?

    在检测到某个Range已经加载完毕后,立刻将“它”的BackgroundQuery属性设为False。因为若仍然保留True,则似乎会占用很大的内存和CPU,就像打开了允许数据后台刷新的功能一样;及时设置为False后,内存和CPU的占用会大大改善。

d、具体代码

将以下代码打包了一个类:ayncRefreshThr

Private isRefreshing As Boolean, asyncRefreshRanges As Object
Private tStart, tEnd As Double, sucMacro As String, asyncN As Long
Private durationPmpt As Boolean

Private Sub Class_Initialize()
  isRefreshing = False '表示异步刷新的状态
  Set asyncRefreshRanges = CreateObject("Scripting.Dictionary")
  '记录待刷新的Range。当处于异步刷新时,若检测到发生变化的Range不在其中,则进行下一步操作。
  asyncRefreshRanges.RemoveAll
  tStart = 0: tEnd = 0 '利用Timer记录起止时刻
  sucMacro = "" '记录异步刷新完成后应执行哪个sub
  durationPmpt = False '异步刷新完成时是否提示用了多长时间
  asyncN = 0 '一共有几个Range待刷新
End Sub

Sub asyncRefresh(rngArr, Optional macroStr = "", Optional durationPrompt = False, Optional singleThdebug As Boolean = False)
' rngArr:是Array,其中每个元素均是String,是待刷新表的Name
' macroStr是异步刷新完成后要执行哪一个sub的名称,是String类型。为空时代表着不执行,不空时,格式是“模块名.sub名”
' singleThdebug用于控制是否使用异步刷新的方式批量刷新一批Range。仅在调试中使用。
Dim i As Integer, tmpstr1, tmpstr2 As String
  If singleThdebug Then
  '一个一个Range地刷,不采用异步刷新。此处仅供调试用。
      If Len(macroStr) <> 0 Then sucMacro = ThisWorkbook.Name & "!" & CStr(macroStr) 'Application.Run其实还要在前面补上工作簿的名称,但是因为肯定是自己内部引用,所以在设计函数时省略,并于此处自动补充上。
      For Each itm In rngArr
          RefreshSheet itm
      Next itm
      If Len(sucMacro) <> 0 Then Application.Run sucMacro
  Else '异步刷新的开始
      tStart = Timer
      durationPmpt = CBool(durationPrompt)
      If Len(macroStr) <> 0 Then sucMacro = ThisWorkbook.Name & "!" & CStr(macroStr)
          For i = 1 To arrLen(rngArr)
          tmpstr1 = CStr(rngArr(LBound(rngArr) + i - 1))
          If Not asyncRefreshRanges.exists(tmpstr1) Then
              asyncRefreshRanges.Add tmpstr1, ""
          End If
      Next i

      '打上生僻字标记
      For Each itm In asyncRefreshRanges.keys
          Range(itm).Cells(1, 1).Value = "飝"
      Next itm

      isRefreshing = True
      For Each itm In asyncRefreshRanges.keys
          Range(itm).ListObject.QueryTable.Refresh BackgroundQuery:=True
      Next itm
      asyncN = asyncRefreshRanges.Count
      Application.StatusBar = "正在异步刷新(0/" & asyncN & "):" & Join(asyncRefreshRanges.keys, "、")
  End If
End Sub

Sub checkStatus() '让Workbook_Change事件触发这个方法
  If isRefreshing Then
      If asyncRefreshOver() Then
          isRefreshing = False: tEnd = Timer
          If durationPmpt Then MsgBox "刷新用时:" & Format(tEnd - tStart, "0.00秒"), vbInformation, "异步刷新完成"
          If Len(sucMacro) <> 0 Then Application.Run sucMacro
      End If
  End If
End Sub

Private Function asyncRefreshOver(Optional statusBarStyle = "live") As Boolean
'statusBarStyle:状态栏展示的样式,和程序主体无关。
Dim n As Integer, isOver As Boolean
  If isRefreshing = False Then
      asyncRefreshOver = True
  Else
      isOver = True
      Select Case statusBarStyle
          Case "process"
              For Each itm In asyncRefreshRanges.keys
                  n = 0 '待累加量,表示有多少个Range完成了刷新
                  If asyncRefreshRanges.Item(itm) = "ok" Then
                      'isOver = isOver And True
                      n = n + 1
                  ElseIf Range(itm).Cells(1, 1) = "飝" Then
                      isOver = False
                  Else
                      asyncRefreshRanges.Item(itm) = "ok"
                      Range(itm).ListObject.QueryTable.BackgroundQuery = False '关闭后台刷新,减少系统资源占用
                      n = n + 1
                  End If
              Next itm
              Application.StatusBar = "正在异步刷新(" & n & "/" & asyncN & "):" & Join(asyncRefreshRanges.keys, "、")
          Case "live"
              For Each itm In asyncRefreshRanges.keys
                  If Range(itm).Cells(1, 1) = "飝" Then
                      isOver = False
                  Else
                      asyncRefreshRanges.Remove (itm)
                      Range(itm).ListObject.QueryTable.BackgroundQuery = False
                  End If
              Next itm
              Application.StatusBar = "正在异步刷新(" & (asyncN - asyncRefreshRanges.Count) & "/" & asyncN & "):" & Join(asyncRefreshRanges.keys, "、")
          Case Else
              isOver = True
      End Select

      If isOver Then
          'MsgBox "刷新完毕!"
          isRefreshing = False
          asyncRefreshRanges.RemoveAll
          Application.StatusBar = False
      End If
      asyncRefreshOver = isOver
  End If
End Function

Private Function arrLen(arr) As Long
  arrLen = UBound(arr) - LBound(arr) + 1
End Function

Private Sub RefreshSheet(RngName) 'RngName是String,是待刷新的Range的Name属性值
  With ThisWorkbook.Connections("查询 - " & RngName).OLEDBConnection
      .BackgroundQuery = False
      .Refresh
  End With
End Sub

在Workbook中设置触发事件:

Private Sub Workbook_SheetChange(ByVal Sh As Object, ByVal Target As Range)
    aRefreshT1.checkStatus
End Sub

在一般的模块中写:

Public aRefreshT1 As New ayncRefreshThr

Sub RefreshSheets(rngArr, Optional macro = "", Optional durationPrompt As Boolean = False)
    aRefreshT1.asyncRefresh rngArr, macro, durationPrompt
    ' rngArr:是Array,其中每个元素是String,表示待刷新Range的Name
    ' macro:完成刷新后执行的本Workbook内的sub,不能带参数。格式写成“模块名.sub名”
    ' durationPrompt:是否提示异地刷新完成时间
End Sub

  1. 自Office 2016起PowerQuery才完全嵌入Excel,在2013版时需要单独安装插件,在更早的版本则无法支持。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,039评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,223评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,916评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,009评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,030评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,011评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,934评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,754评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,202评论 1 309
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,433评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,590评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,321评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,917评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,568评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,738评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,583评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,482评论 2 352

推荐阅读更多精彩内容