我在做一個圖表工具軟體,這個軟體使用 MAUI 開發,我的需求是圖表的內容需要和 PPT 的圖表對接,需要用到 OpenXML 決議 PPT 內容,讀取到 PPT 圖表元素的內容,接著使用 MAUI 渲染層繪制圖表元素,圖表工具軟體需要在 Windows 平臺和 Linux 平臺上運行,在 Windows 下,我采用 WPF 應用,用來辟謠說 MAUI 不支持 WPF 應用, 在 Linux 選用 Ubuntu 系統,采用 GTKSharp 應用加上 Skia 渲染對接 MAUI 框架
圖表工具軟體的開發架構如下,可以看到只有和具體平臺對接的一層不相同

本文將包含兩個部分,一個是決議渲染面積圖圖表,另一個是使用 MAUI 開發跨平臺應用,決議面積圖圖表是用到 OpenXML 決議 PPT 的知識,本文只包含很少量的 OpenXML 的知識,我將詳細的使用 OpenXML 決議 PPT 的面積圖的方法放在了 dotnet OpenXML 決議 PPT 圖表 面積圖入門 博客里,本文的用到的決議 PPT 的代碼也是從此博客里面抄的,這部分代碼將不會在本文上貼出, 如對 OpenXML 決議 PPT 毫無概念的伙伴,閱讀本文也不會存在問題,只需要假定本文的決議 PPT 的代碼是通過某個方式獲取到了圖表的相關資訊即可,請將重點放在圖表的繪制渲染,以及如何做跨平臺對接上
本文使用的代碼只能用來做例子,本文的決議 PPT 圖表的代碼只能支持本文例子里的測驗檔案,本文的測驗檔案和代碼可以從本文最后獲取
在開始之前,先看一下本文實作的效果
效果
這是在 PPT 的圖表:

在 Windows 下,使用 Skia 繪制為圖片檔案,然后使用 Image 控制元件顯示圖片,界面效果如下:

以上只是將 MAUI 接入 WPF 的一個方法,不代表只能通過圖片檔案的方式接入,其他繪制方法請看 WPF 使用 MAUI 的自繪制邏輯
在 Linux 下,使用 Skia 對接 Gtk 框架,界面效果如下:

動態運行效果如下

接下來將告訴大家如何實作
決議繪制面積圖圖表
開始實作繪制 PPT 的圖表之前,需要先決議圖表的內容
圖表的決議部分需要用到 OpenXML 知識,這部分決議的內容,在 dotnet OpenXML 決議 PPT 圖表 面積圖入門 博客里面有詳細說明,使用 dotnet OpenXML 決議 PPT 圖表 面積圖入門 的方法決議出圖表的內容將獲取到的內容放入到 AreaChartRenderContext 型別,此型別用來提供渲染繪制使用的背景關系,包括以下屬性
/// <summary>
/// 用來提供圖表 面積圖 渲染的背景關系資訊
/// </summary>
public class AreaChartRenderContext
{
// 忽略代碼
public ChartSpace ChartSpace { get; }
public SlideContext SlideContext { get; }
public Pixel Width { get; }
public Pixel Height { get; }
/// <summary>
/// 類別軸上的資料 橫坐標軸上的資料
/// </summary>
public ChartValueList CategoryAxisValueList { get; } = null!;
/// <summary>
/// 面積圖的系列資訊集合
/// </summary>
public AreaChartSeriesInfoList AreaChartSeriesInfoList { get; } = new();
}
上面代碼的 ChartSpace 屬性是圖表元素,通過 dotnet OpenXML 決議 PPT 圖表 面積圖入門 博客可以了解到里面包含圖表的資訊,上面代碼的 SlideContext 屬性是我所在的團隊開源的 OpenXml 決議輔助庫提供的包含元素所在頁面的型別,詳細請看: https://github.com/dotnet-campus/DocumentFormat.OpenXml.Extensions
圖表關鍵的資訊包含類別軸上的資料,也稱為橫坐標軸上的資料,放在 CategoryAxisValueList 屬性,系列資訊集合,放在 AreaChartSeriesInfoList 屬性,這兩個屬性是從 ChartSpace 讀取,讀取的方法請看 dotnet OpenXML 決議 PPT 圖表 面積圖入門 博客或者閱讀本文用到的代碼
在獲取到了圖表的各個資訊之后,即可進行繪制圖表,開始進行繪制之前,還請先了解圖表的各個組成部分
- 橫坐標軸 類別坐標軸資料:

- 縱坐標軸:

- 資料系列:
在圖表里面有資料系列的概念,每個系列的資料組成一個個的資料系列,對于大部分圖表來說,資料層都是由一個個資料系列組成的
每個資料系列可以有自己的系列名稱

系列名稱大部分時候都放在圖例里面,也就是圖例里面的內容就是由系列名稱提供的
在圖表里面,核心就是對資料的處理,系列的資料內容就是核心的

如圖,面積圖有兩個資料系列,通過上面的 Excel 內容可以了解到兩個系列的資料分別如下
系列 1:32,32,28,12,15
系列 2:12,12,12,21,28
為了讓繪制邏輯更方便閱讀,定義 AreaChartRender 類用來繪制圖表
圖表繪制 AreaChartRender 需要兩個引數,一個是 AreaChartRenderContext 用來提供資訊,一個是 Microsoft.Maui.Graphics.ICanvas 用來提供渲染繪制方法,在各個平臺上,可以使用不同的實作對接 MAUI 的渲染,也就是 Microsoft.Maui.Graphics.ICanvas 介面可以對應不同的實作,在決議渲染模塊里不耦合具體的平臺渲染實作,只使用抽象的介面,定義的型別如下
public class AreaChartRender
{
public AreaChartRender(AreaChartRenderContext context)
{
Context = context;
}
public AreaChartRenderContext Context { get; }
public void Render(ICanvas canvas)
{
// 忽略代碼
}
圖表繪制 AreaChartRender 基礎的使用方法是在和 OpenXML 決議 PPT 的圖表這一層對接,通過 AreaChartRenderContext 型別拿到圖表的內容,創建出 AreaChartRender 物件,傳遞給具體的渲染層,在渲染層里,將區分平臺進行渲染,各個平臺定義 Microsoft.Maui.Graphics.ICanvas 的實作,傳入到 AreaChartRender 的 Render 方法,在 Render 方法將繪制圖表內容,即可通過抽象的 Microsoft.Maui.Graphics.ICanvas 介面,呼叫各個平臺具體的繪制實作
使用以下代碼即可使用 OpenXML 決議 PPT 的圖表,獲取圖表內容,關于以下代碼的細節邏輯,請看 dotnet OpenXML 決議 PPT 圖表 面積圖入門
public class ModelReader
{
/// <summary>
/// 構建出面積圖背景關系
/// </summary>
/// <param name="file">這里是例子,要求只能傳入 Test.pptx 檔案,其他檔案沒有支持</param>
/// <returns></returns>
public AreaChartRender BuildAreaChartRender(FileInfo file)
{
using var presentationDocument = PresentationDocument.Open(file.FullName, false);
var slide = presentationDocument.PresentationPart!.SlideParts.First().Slide;
/*
<p:cSld>
<p:spTree>
<p:graphicFrame>
...
</p:graphicFrame>
</p:spTree>
</p:cSld>
*/
// 獲取圖表元素,在這份課件里,有一個面積圖,以下使用 First 忽略細節,獲取圖表
var graphicFrame = slide.Descendants<GraphicFrame>().First();
/*
<p:graphicFrame>
<a:graphic>
<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">
<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2" />
</a:graphicData>
</a:graphic>
</p:graphicFrame>
*/
// 獲取到對應的圖表資訊,圖表是參考的,內容不是放在 Slide 頁面里面,而是放在獨立的圖表 xml 檔案里
var graphic = graphicFrame.Graphic;
var graphicData = https://www.cnblogs.com/lindexi/archive/2022/09/09/graphic?.GraphicData;
var chartReference = graphicData?.GetFirstChild();
// 獲取到 id 也就是 `r:id="rId2"` 根據 Relationship 的描述,可以知道去 rels 檔案里面獲取關聯的內容,在 OpenXml SDK 里,封裝好了獲取方法,獲取時需要有兩個引數,一個是 id 另一個是去哪里獲取的 Part 內容
var id = chartReference?.Id?.Value;
// 這里需要告訴 OpenXml SDK 去哪里獲取資源,詳細請看 https://blog.lindexi.com/post/dotnet-OpenXML-%E4%B8%BA%E4%BB%80%E4%B9%88%E8%B5%84%E6%BA%90%E4%BD%BF%E7%94%A8-Relationship-%E5%BC%95%E7%94%A8.html
// 如果是放在模版里面,記得要用模版的 Part 去獲取
var currentPart = slide.SlidePart!;
if (!currentPart.TryGetPartById(id!, out var openXmlPart))
{
// 在這份課件里,一定不會進入此分支
// 一定能從頁面找到對應的資源內容也就是圖表
}
var chartPart = (ChartPart) openXmlPart!;
// 這里的 ChartPart 對應的就是 charts\chartN.xml 檔案,這里的 chartN.xml 表示的是 chart1.xml 或 chart2.xml 等檔案
var chartSpace = chartPart.ChartSpace;
var slideContext = new SlideContext(slide, presentationDocument);
var transformData = https://www.cnblogs.com/lindexi/archive/2022/09/09/graphicFrame.GetOrCreateTransformData(slideContext);
return new AreaChartRender(new AreaChartRenderContext(chartSpace, slideContext, transformData.Width.ToPixel(),
transformData.Height.ToPixel()));
}
}
具體的平臺渲染實作部分,放在下一章,下面先在 Render 方法對接 MAUI 的抽象的 Microsoft.Maui.Graphics.ICanvas 介面,進行繪制圖表,繪制圖表的作業量包括繪制坐標軸資訊,計算刻度線,對各個系列的繪制
本文這里采用的是絕對布局方式,相對來說用到的知識簡單,缺點是很多計算都會放在下面代碼,看起來比較復雜,好在計算只是小學數學的加減
下面的繪制代碼只能作為本文的例子使用,很多原本需要進行排版計算的值,為了方便理解,我都使用常量,如下面代碼,還請忽略這部分的細節
public void Render(ICanvas canvas)
{
// 圖表標題
float chartTitleHeight = 52;
// 圖例高度,圖例是放在最下方
float chartLegendHeight = 42;
// 類別資訊的高度
float axisValueHeight = 20;
float yAxisLeftMargin = 42;
float yAxisRightMargin = 42;
float xAxisBottomMargin = 25;
}
從 OpenXML 決議的 PPT 圖表獲取到的 AreaChartRenderContext 拿到圖表的元素尺寸,用來作為圖表繪制畫布的限制尺寸
var chartWidth = (float) Context.Width.Value;
var chartHeight = (float) Context.Height.Value;
以上的數值定義全部采用 float 型別,其原因是 MAUI 為了更好的適配更多的平臺,選用了 float 作為渲染繪制的引數的通用型別,這一點和 WPF 的不相同,在 WPF 或 UWP 或 WinFroms 等,通用的繪制計算都采用 double 型別,對于渲染繪制,大部分情況,使用 float 也是夠用的,如果一個 double 值的范圍是在 float 內,那進行 double 轉 float 也是安全的,至于性能的損耗,如果不是熱點代碼,也可以忽略
通過以上的資訊即可計算出圖表的繪制范圍,包括坐標和尺寸
var plotAreaOffsetX = yAxisLeftMargin;
var plotAreaOffsetY = chartTitleHeight;
var plotAreaWidth = chartWidth - yAxisLeftMargin - yAxisRightMargin;
var plotAreaHeight = chartHeight - chartTitleHeight - chartLegendHeight - xAxisBottomMargin;
這些資訊屬于布局資訊,本文這里只是使用簡單的固定數值計算,而不是跟隨具體的圖表資料進行計算,以上的代碼比較“塑料”還請不要抄到實際專案代碼,完成布局計算之后,開始繪制坐標軸資訊,坐標軸資訊包含了刻度資訊,也就是 Y 軸的刻度,刻度資訊包括了每個刻度之間的數值間隔是多少,最大值和最小值是多少的資訊,我采用了玄學的計算方法 GetRatio 獲取到了刻度的間隔的值,以及和這份 PPT 的圖表一樣固定了只有 8 條線
var rowLineCount = 8; // 這份 PPT 測驗檔案里只有 8 條線
// 獲取資料最大值
var maxData = https://www.cnblogs.com/lindexi/archive/2022/09/09/GetMaxValue();
// 獲取刻度的值
var ratio = GetRatio(maxData, rowLineCount); // 這是一個玄學的方法,才不告訴你方法里面直接回傳了一個常量
var maxValue = ratio * (rowLineCount - 1);
完成了基礎計算,接下來可以開始繪制坐標軸,繪制坐標軸就需要用到 MAUI 的繪制知識,對這些繪制知識感興趣還請參閱官方檔案: Graphics - .NET MAUI Microsoft Docs
繪制坐標軸,本質上是繪制網格線,步驟是先繪制 Y 軸,再繪制 X 軸,如 PPT 的圖表效果,這份檔案的 Y 軸只有刻度,也就是需要繪制 Y 軸的刻度和 x 行的線,在 MAUI 里,繪制線條只需要使用 DrawLine 方法,傳入兩個點即可,控制線條的粗細和顏色等,是通過在 DrawLine 方法之前,先設定好引數屬性,如下面代碼繪制 X 行的線
for (var i = 0; i < rowLineCount; i++)
{
canvas.StrokeSize = 2;
canvas.StrokeColor = Colors.Gray;
var offsetX = plotAreaOffsetX;
var offsetY = plotAreaOffsetY + plotAreaHeight - plotAreaHeight / (rowLineCount - 1) * i;
canvas.DrawLine(offsetX, offsetY, offsetX + plotAreaWidth, offsetY);
}
以上代碼通過 StrokeSize 設定繪制的線條的粗細是 2 的值,這里的值是沒有一個單位的,具體的單位是具體的渲染平臺自己賦予的,可以認為是像素,使用 StrokeColor 設定線條的顏色,再使用 DrawLine 傳入兩個點,繪制出線條
接下來繼續繪制 Y 軸的刻度,繪制刻度需要用到文本繪制的方法,文本繪制中存在一個小問題,那就是中文字體設定的問題,好在此問題被我修復了,詳細請看 Fix set the Font to Microsoft.Maui.Graphics.Skia by lindexi · Pull Request #9124 · dotnet/maui
以下代碼只是繪制數字而已,不需要設定中文字體,也就不會踩到上文說到的坑,為了讓繪制文本對齊到刻度,需要給定繪制文本的范圍,這里稍微有一些知識需要了解,詳細請看 Microsoft.Maui.Graphics.Skia 使用 DrawString 繪制文本的坐標問題
// 獲取刻度的值
var fontSize = 16f;
canvas.FontSize = fontSize;
var textRightMargin = 5;
var textX = 0;
var textY = offsetY - fontSize / 2f;
var textWidth = plotAreaOffsetX - textX - textRightMargin;
var textHeight = 25;
// 獲取刻度的文本
var value = https://www.cnblogs.com/lindexi/archive/2022/09/09/(ratio * i).ToString(CultureInfo.CurrentCulture);
canvas.DrawString(value, textX, textY, textWidth, textHeight, HorizontalAlignment.Right, VerticalAlignment.Top);
和繪制線條相同的是,在繪制文本之前,通過引數屬性設定文本的屬性,例如上面代碼設定了文本的字體大小,同樣,這里的字體大小也是沒有具體單位的,由具體的平臺實作決定,大部分情況可以認為是像素單位
完成了繪制 Y 軸的刻度和 x 行的線,繼續繪制放在 X 軸底部的類別資訊,也就是對應本文的圖表的日期資訊,好在日期的表示的字串也沒有用到中文,依然不會踩到上文描述的中文字體的坑
// 繪制 X 軸,繪制類別資訊
var categoryAxisValueList = Context.CategoryAxisValueList.ValueList;
for (var i = 0; i < categoryAxisValueList.Count; i++)
{
var offsetX = plotAreaOffsetX + plotAreaWidth * i / (categoryAxisValueList.Count - 1);
var offsetY = plotAreaOffsetY + plotAreaHeight;
var textX = offsetX - 20;
var textY = offsetY + xAxisBottomMargin;
if (i < categoryAxisValueList.Count)
{
var text = categoryAxisValueList[i].GetViewText();
canvas.DrawString(text, textX, textY, HorizontalAlignment.Left);
}
}
繪制類別資訊的作業量就是計算出文本的坐標,和使用 GetViewText 方法,獲取到具體類別里的用戶可見的文本的字串,然后呼叫 DrawString 方法即可
完成坐標軸的繪制之后,就進入關鍵的 DrawArea 方法,在此方法里面,將會繪制圖表的資料資訊,將圖表的各個系列的資料作為面積圖繪制
繪制面積圖圖表的方法是獲取到圖表的各個系列的數值資訊,根據這些數值創建出一段 Path Geometry 路徑幾何用于填充面積圖,創建路徑幾何可使用 PathF 型別創建一個基于 float 存盤資訊的路徑幾何,這里的 PathF 就是 Path + Float 的意思,如以下代碼進行創建
using var path = new PathF();
在 MAUI 里,這個 PathF 是推薦做釋放的,在各個平臺的 PathF 的底層實作有所不同,不代表著一定需要釋放,好在多呼叫釋放是安全的,這里就加上 using 用來在方法執行結束釋放,開始繪制之前,先準備一點點路徑幾何創建的知識,按照 Path 的創建慣例,開始點采用 Move 方法設定,如以下代碼
path.Move(startX, startY);
在 MAUI 的設計里,可以使用連續的方法,輸入繪制引數,如畫兩條線,然后設定幾何關閉,可以采用如下代碼
path.LineTo(x1, y1)
.LineTo(x2, y2)
.Close();
如上面代碼即可畫出一段路徑集合出來,本文會用到的也僅僅只是以上幾個方法,這也就是本文用到的核心繪制路徑的知識,當然,路徑幾何 PathF 是一個復雜的型別,擁有的方法和功能可遠不止本文介紹的這一點,更多繪制知識,還請參閱官方檔案,在了解了基礎用法,接下來開始繪制面積圖
繪制面積圖只是一些計算邏輯,通過給定的資料計算出 PathF 的內容,代碼如下
for (var chartDataIndex = 0; chartDataIndex < Context.AreaChartSeriesInfoList.Count; chartDataIndex++)
{
var chartSeriesInfo = Context.AreaChartSeriesInfoList[chartDataIndex];
if (chartSeriesInfo.ChartValueList is null)
{
continue;
}
using var path = new PathF();
var startX = plotAreaOffsetX;
var startY = plotAreaOffsetY + plotAreaHeight;
path.Move(startX, startY);
for (var i = 0; i < chartSeriesInfo.ChartValueList.ValueList.Count; i++)
{
var value = https://www.cnblogs.com/lindexi/archive/2022/09/09/chartSeriesInfo.ChartValueList.ValueList[i];
if (value is NumericChartValue numericChartValue)
{
var offsetX = plotAreaOffsetX + plotAreaWidth * i / (categoryAxisValueList.Count - 1);
var offsetY = plotAreaOffsetY + plotAreaHeight -
numericChartValue.GetValue() / maxValue * plotAreaHeight;
path.LineTo(offsetX, (float) offsetY);
}
}
path.LineTo(plotAreaOffsetX + plotAreaWidth, plotAreaOffsetY + plotAreaHeight)
.LineTo(startX, startY)
.Close();
}
創建 path 路徑完成,即可繪制到畫布,按照慣例,繪制需要先設定填充顏色,再繪制
// 在這份課件里,一定是純色
var (success, a, r, g, b) =
BrushCreator.ConvertToColor(chartSeriesInfo.FillBrush!.GetFill<SolidFill>()!.RgbColorModelHex!.Val!);
var color = new Color(r, g, b, a); // 獲取到各個系列的填充顏色
canvas.FillColor = color;
canvas.FillPath(path);
以上簡單的代碼即可完成圖表的繪制,我將上面代碼放在一個方法,方便大家閱讀
public void Render(ICanvas canvas)
{
var chartWidth = (float) Context.Width.Value;
var chartHeight = (float) Context.Height.Value;
// 圖表標題
float chartTitleHeight = 52;
// 圖例高度,圖例是放在最下方
float chartLegendHeight = 42;
// 類別資訊的高度
float axisValueHeight = 20;
float yAxisLeftMargin = 42;
float yAxisRightMargin = 42;
float xAxisBottomMargin = 25;
var plotAreaOffsetX = yAxisLeftMargin;
var plotAreaOffsetY = chartTitleHeight;
var plotAreaWidth = chartWidth - yAxisLeftMargin - yAxisRightMargin;
var plotAreaHeight = chartHeight - chartTitleHeight - chartLegendHeight - xAxisBottomMargin;
// void CreateCoordinate()
// 繪制坐標系
// 先找到 Y 軸的刻度,找到最大值
// 有多少條行的線,保持和 PPT 相同
var rowLineCount = 8;
// 獲取資料最大值
var maxData = https://www.cnblogs.com/lindexi/archive/2022/09/09/GetMaxValue();
// 獲取刻度的值
var ratio = GetRatio(maxData, rowLineCount);
var maxValue = ratio * (rowLineCount - 1);
// 繪制網格線,先繪制 Y 軸,再繪制 X 軸
// 繪制 Y 軸的刻度和 x 行線
for (var i = 0; i < rowLineCount; i++)
{
canvas.StrokeSize = 2;
canvas.StrokeColor = Colors.Gray;
var offsetX = plotAreaOffsetX;
var offsetY = plotAreaOffsetY + plotAreaHeight - plotAreaHeight / (rowLineCount - 1) * i;
canvas.DrawLine(offsetX, offsetY, offsetX + plotAreaWidth, offsetY);
// 獲取刻度的值
var fontSize = 16f;
canvas.FontSize = fontSize;
var textRightMargin = 5;
var textX = 0;
var textY = offsetY - fontSize / 2f;
var textWidth = plotAreaOffsetX - textX - textRightMargin;
var textHeight = 25;
var value = (ratio * i).ToString(CultureInfo.CurrentCulture);
canvas.DrawString(value, textX, textY, textWidth, textHeight, HorizontalAlignment.Right,
VerticalAlignment.Top);
}
// 繪制 X 軸,繪制類別資訊
var categoryAxisValueList = Context.CategoryAxisValueList.ValueList;
for (var i = 0; i < categoryAxisValueList.Count; i++)
{
var offsetX = plotAreaOffsetX + plotAreaWidth * i / (categoryAxisValueList.Count - 1);
var offsetY = plotAreaOffsetY + plotAreaHeight;
var textX = offsetX - 20;
var textY = offsetY + xAxisBottomMargin;
if (i < categoryAxisValueList.Count)
{
var text = categoryAxisValueList[i].GetViewText();
canvas.DrawString(text, textX, textY, HorizontalAlignment.Left);
}
}
// void DrawArea()
// 繪制內容
for (var chartDataIndex = 0; chartDataIndex < Context.AreaChartSeriesInfoList.Count; chartDataIndex++)
{
var chartSeriesInfo = Context.AreaChartSeriesInfoList[chartDataIndex];
if (chartSeriesInfo.ChartValueList is null)
{
continue;
}
using var path = new PathF();
var startX = plotAreaOffsetX;
var startY = plotAreaOffsetY + plotAreaHeight;
path.Move(startX, startY);
for (var i = 0; i < chartSeriesInfo.ChartValueList.ValueList.Count; i++)
{
var value = chartSeriesInfo.ChartValueList.ValueList[i];
if (value is NumericChartValue numericChartValue)
{
var offsetX = plotAreaOffsetX + plotAreaWidth * i / (categoryAxisValueList.Count - 1);
var offsetY = plotAreaOffsetY + plotAreaHeight -
numericChartValue.GetValue() / maxValue * plotAreaHeight;
path.LineTo(offsetX, (float) offsetY);
}
}
path.LineTo(plotAreaOffsetX + plotAreaWidth, plotAreaOffsetY + plotAreaHeight)
.LineTo(startX, startY)
.Close();
if (chartDataIndex < Context.AreaChartSeriesInfoList.Count)
{
// 在這份課件里,一定是純色
var (success, a, r, g, b) =
BrushCreator.ConvertToColor(chartSeriesInfo.FillBrush!.GetFill()!.RgbColorModelHex!
.Val!);
var color = new Color(r, g, b, a);
canvas.FillColor = color;
}
canvas.FillPath(path);
}
}
原本是將上面代碼拆開作為多個函式,為了方便除錯,還是放在一個函式里,在實際專案上,不要讓一個方法的代碼如此多
開發跨平臺應用
完成圖表的繪制邏輯,接下來需要各個平臺進行對接,與 MAUI 的對接是十分簡單的,按照慣例,是先安裝 NuGet 庫,然后呼叫庫提供的方法即可完成對接,先對接 Windows 平臺的 WPF 應用
在 WPF 應用里,這次采用的是對接圖片檔案渲染方法,如本文開始的開發架構圖所述,在 Windows 上通過 Microsoft.Maui.Graphics.Skia 將 Skia 和 MAUI 對接,使用 Skia 作為 MAUI 的畫布,在繪制完成之后使用 Skia 保存本地圖片檔案,再使用 WPF 渲染保存的圖片
這不代表著在 WPF 里面,只能通過 Skia 才能和 MAUI 對接,也不代表著 WPF 對接 Skia 只能通過本地圖片的顯示,關于在 WPF 里面,直接對接 MAUI 的方法請看 WPF 使用 MAUI 的自繪制邏輯
關于在 WPF 里面,使用 WriteableBitmap 控制元件作為 Skia 的輸出的方式,讓 WPF 對接 Skia 的方法請看 WPF 使用 Skia 繪制 WriteableBitmap 圖片
回到對接的邏輯,由于本文的 WPF 應用只負責將 Skia 保存的圖片進行渲染,也就是說 WPF 層是可以不知道任何 MAUI 和 Skia 的邏輯,只需要知道保存的圖片檔案在哪即可,既然沒有什么 WPF 的邏輯,那就先來關注一下 Skia 的對接邏輯
這里的 Skia 邏輯包括兩個部分,一個是 Skia 輸出到本地圖片檔案,另一個是 Skia 對接 MAUI 的邏輯,關于 Skia 對接 MAUI 的邏輯,細節可參閱 dotnet 控制臺 使用 Microsoft.Maui.Graphics 配合 Skia 進行繪圖入門 檔案,本文將不包含細節邏輯
開始之前,按照慣例先安裝 NuGet 庫,在 dotnet 6 應用里,通過編輯 csproj 專案檔案的方式可以快速安裝 NuGet 庫,在 csproj 檔案上加上以下代碼用來安裝 NuGet 庫,安裝的 NuGet 庫包括用來決議 PPT 的 dotnetCampus.DocumentFormat.OpenXml.Flatten 和 dotnetCampus.OpenXmlUnitConverter 和 DocumentFormat.OpenXml 庫,和 MAUI 的 Microsoft.Maui.Graphics 和 Microsoft.Maui.Graphics.Skia 庫
<ItemGroup>
<PackageReference Include="dotnetCampus.DocumentFormat.OpenXml.Flatten" Version="2.1.0" />
<PackageReference Include="dotnetCampus.OpenXmlUnitConverter" Version="2.1.0" />
<PackageReference Include="DocumentFormat.OpenXml" Version="2.17.1" />
<PackageReference Include="Microsoft.Maui.Graphics" Version="6.0.403" />
<PackageReference Include="Microsoft.Maui.Graphics.Skia" Version="6.0.403" />
</ItemGroup>
為了方便開發,我將 Skia 對接 MAUI 的邏輯,封裝到 SkiaPngImageRenderCanvas 型別,此型別繼承 IRenderCanvas 介面,介面定義如下
/// <summary>
/// 渲染畫板
/// </summary>
public interface IRenderCanvas
{
/// <summary>
/// 開始進行渲染
/// </summary>
/// <param name="action"></param>
void Render(Action<ICanvas> action);
}
通過呼叫 Render 方法,傳入委托,委托的引數就是 Microsoft.Maui.Graphics.ICanvas 介面,在此委托里面完成實際的繪制邏輯
創建 SkiaPngImageRenderCanvas 需要三個引數,分別是寬度高度的畫布尺寸,也就是保存的圖片的尺寸,這里的單位是像素,和保存的檔案,上層業務呼叫 Render 完成,將輸出檔案
/// <summary>
/// 提供使用 png 作為輸出的 Skia 畫板
/// </summary>
public class SkiaPngImageRenderCanvas : IRenderCanvas
{
private readonly int _width;
private readonly int _height;
private readonly FileInfo _outputPngFile;
public SkiaPngImageRenderCanvas(int width, int height, FileInfo outputPngFile)
{
_width = width;
_height = height;
_outputPngFile = outputPngFile;
}
public void Render(Action<ICanvas> action)
{
// 忽略代碼
}
在 Render 方法里,將先創建 Skia 的畫布,接著使用 Skia 的畫布創建 MAUI 的畫布,將 MAUI 的畫布傳入到委托作為引數,繪制完成保存本地檔案
在 Skia 里面,最重要的概念是畫布 SKCanvas 型別,基本的繪制邏輯都是呼叫此型別的方法完成,通過此型別即可在上面繪制內容,而 Skia 與 MAUI 的對接里,也需要用到此型別,對接的方法是創建 Microsoft.Maui.Graphics.Skia.SkiaCanvas 物件,此 SkiaCanvas 物件繼承了 Microsoft.Maui.Graphics.ICanvas 介面,即可用來傳入圖表的繪制層作為繪制的畫布
初始化 SkiaCanvas 物件就需要用到 SKCanvas 物件,以下代碼包含了創建 SKCanvas 物件和使用 SKCanvas 物件創建出 SkiaCanvas 物件
public void Render(Action<ICanvas> action)
{
var skImageInfo = new SKImageInfo(_width, _height, SKColorType.Bgra8888, SKAlphaType.Unpremul, SKColorSpace.CreateSrgb());
using (var skImage = SKImage.Create(skImageInfo))
{
using (var skBitmap = SKBitmap.FromImage(skImage))
{
using (var skCanvas = new SKCanvas(skBitmap))
{
skCanvas.Clear(SKColors.Transparent);
var skiaCanvas = new SkiaCanvas();
skiaCanvas.Canvas = skCanvas;
ICanvas canvas = skiaCanvas;
action(canvas);
// 忽略代碼
}
}
}
}
接著在執行 action 委托完成之后,保存為本地圖片,代碼如下
skCanvas.Clear(SKColors.Transparent);
var skiaCanvas = new SkiaCanvas();
skiaCanvas.Canvas = skCanvas;
ICanvas canvas = skiaCanvas;
action(canvas);
skCanvas.Flush();
using (var skData = https://www.cnblogs.com/lindexi/archive/2022/09/09/skBitmap.Encode(SKEncodedImageFormat.Png, 100))
{
var file = _outputPngFile;
using (var fileStream = file.OpenWrite())
{
fileStream.SetLength(0);
skData.SaveTo(fileStream);
}
}
以上代碼忽略細節邏輯,更多對接細節請看 dotnet 控制臺 使用 Microsoft.Maui.Graphics 配合 Skia 進行繪圖入門
以上就完成了 Skia 的對接,接下來就交給 WPF 層,將 OpenXML 決議和 Skia 和 MAUI 對接一起
先對接 OpenXML 決議 PPT 圖表的邏輯,獲取測驗檔案,將測驗檔案傳入 ModelReader 構建出 AreaChartRender 用來繪制,如此即可完成 OpenXML 的對接
var file = new FileInfo("Test.pptx");
var modelReader = new ModelReader();
var areaChartRender = modelReader.BuildAreaChartRender(file);
接著定義輸出的本地圖片,創建 SkiaPngImageRenderCanvas 用來做畫布,這里是隨便找一個檔案用來輸出
var tempFile = Path.GetTempFileName();
var outputFile = new FileInfo(tempFile);
var skiaPngImageRenderCanvas = new SkiaPngImageRenderCanvas((int) Math.Ceiling(areaChartRender.Context.Width.Value), (int) Math.Ceiling(areaChartRender.Context.Height.Value), outputFile);
讓 AreaChartRender 使用 Skia 提供的畫布進行渲染,這就是關鍵的對接代碼
skiaPngImageRenderCanvas.Render(areaChartRender.Render);
如此即可將讓圖表繪制到 SkiaPngImageRenderCanvas 提供的 SkiaCanvas 物件上,最終使用 SKCanvas 保存到本地檔案
最后一步就是在 WPF 里面將保存的檔案在界面顯示
var image = new Image()
{
Width = areaChartRender.Context.Width.Value,
Height = areaChartRender.Context.Height.Value,
Margin = new Thickness(10, 10, 10, 10),
Source = new BitmapImage(new Uri(outputFile.FullName))
};
Root.Children.Add(image);
以上的 Root 是一個放在 XAML 的 Grid 元素
<Grid x:Name="Root"></Grid>
這就是在 WPF 上對接的方法,所有的代碼如下
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
Loaded += MainWindow_Loaded;
}
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
var file = new FileInfo("Test.pptx");
var modelReader = new ModelReader();
var areaChartRender = modelReader.BuildAreaChartRender(file);
var tempFile = Path.GetTempFileName();
var outputFile = new FileInfo(tempFile);
var skiaPngImageRenderCanvas = new SkiaPngImageRenderCanvas((int) Math.Ceiling(areaChartRender.Context.Width.Value), (int) Math.Ceiling(areaChartRender.Context.Height.Value), outputFile);
skiaPngImageRenderCanvas.Render(areaChartRender.Render);
var image = new Image()
{
Width = areaChartRender.Context.Width.Value,
Height = areaChartRender.Context.Height.Value,
Margin = new Thickness(10, 10, 10, 10),
Source = new BitmapImage(new Uri(outputFile.FullName))
};
Root.Children.Add(image);
}
}
運行效果如下

可以看到在 Windows 下,通過 WPF 對接 MAUI 是十分簡單的
下面開始對接 Linux 平臺的應用,在 Linux 平臺上使用 GtkSharp 框架做應用,依然使用 Skia 做 MAUI 的渲染層
在 Linux 平臺上的對接分為多個任務:
- 創建 GtkSharp 應用
- 將 Skia 與 GtkSharp 對接
- 將 Skia 與 MAUI 的對接
上文已經有了 Skia 和 MAUI 的對接邏輯的細節,接下來將跳過 Skia 與 MAUI 的對接部分的細節邏輯,本文接下來將重點放在如何創建 GtkSharp 應用以及將 Skia 與 GtkSharp 對接上
在開始 GtkSharp 應用的創建之前,需要先聊一點歷史,嗯,本考古學家要聊的不是上古的歷史了,只是聊聊現代的歷史,關于上古的 Gtk 的故事,還請自行查詢,回到歷史故事上,很久之前 mono 組織就創建了 https://github.com/mono/gtk-sharp 倉庫,此倉庫在 2020 之前還能勉力支持,但漸漸就跟不上 gtk 的發展了,只能支持到 gtk2 的版本,后來大佬們專門給 GtkSharp 創建了組織和倉庫,在 mono 組織的 gtk-sharp 的基礎上繼續維護,現在支持到了 gtk3 的版本,請看 https://github.com/GtkSharp/GtkSharp
本文創建的 GtkSharp 應用,就是使用 https://github.com/GtkSharp/GtkSharp 提供的支持
手動創建的方法是先創建一個 dotnet 6 的控制臺應用,接著編輯 csproj 檔案,修改為以下代碼,安裝 GtkSharp 和 SkiaSharp.Views.Gtk3 庫,如以下代碼可以了解到創建一個 GtkSharp 專案十分簡單,只需要安裝上支持 .NET Standard 2.0 及以上框架的 GtkSharp 庫即可
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<None Remove="**\*.glade" />
<EmbeddedResource Include="**\*.glade">
<LogicalName>%(Filename)%(Extension)</LogicalName>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<PackageReference Include="GtkSharp" Version="3.24.24.*" />
<PackageReference Include="SkiaSharp.Views.Gtk3" Version="2.88.0" />
<PackageReference Include="dotnetCampus.DocumentFormat.OpenXml.Flatten" Version="2.1.0" />
<PackageReference Include="dotnetCampus.OpenXmlUnitConverter" Version="2.1.0" />
<PackageReference Include="DocumentFormat.OpenXml" Version="2.17.1" />
<PackageReference Include="Microsoft.Maui.Graphics" Version="6.0.403" />
<PackageReference Include="Microsoft.Maui.Graphics.Skia" Version="6.0.403" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.0" />
</ItemGroup>
</Project>
其實 https://github.com/GtkSharp/GtkSharp 倉庫的細節還是做的很好的,除了以上手工創建的方法外,還可以通過 dotnet new 命令創建專案,以下是使用 dotnet new 命令創建專案的方法
第一步是安裝 dotnet new 模版,在控制臺命令列輸入以下代碼即可進行安裝
dotnet new --install GtkSharp.Template.CSharp
安裝完成之后,即可使用如下命令創建專案,請將下面命令的 MyApplication 替換為你的專案名
dotnet new gtkapp -o MyApplication
創建好了 GtkSharp 專案和安裝完成了必要的 NuGet 包之后,接下來是讓 Skia 和 GtkSharp 進行對接,在開始對接之前,需要說明的是,我推薦是在 Ubuntu 上構建和運行此專案,而不是在 Windows 上運行,盡管 GtkSharp 聲稱是支持 Windows 平臺的,而且 https://github.com/GtkSharp/GtkSharp 倉庫也做了很多輔助構建作業,但是實際在 Windows 平臺上的構建體驗還是比較鬧心的,為什么這么說?構建的第一步是需要將依賴下載了,依賴放在 https://github.com/GtkSharp/Dependencies 倉庫里,將依賴下載到 %LocalAppData%\Gtk\3.24.24\gtk.zip 檔案,然而這是一個 50MB 左右的檔案,在國內的垃圾網速下……
如果想要在 Windows 下構建,同時嫌棄拉 gtk-3.24.24.zip 的速度太慢,可以試試我上傳到 CSDN 下載的資源 https://download.csdn.net/download/lindexi_gd/86362889
如果構建成功,但是運行提示 System.DllNotFoundException: Gtk: libgtk-3-0.dll 失敗,請參閱 https://github.com/GtkSharp/GtkSharp/issues/337
回到讓 Skia 和 GtkSharp 進行對接的邏輯,編輯 MainWindow.glade 檔案,替換為以下代碼
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.1 -->
<interface>
<requires lib="gtk+" version="3.18"/>
<object id="MainWindow">
<property name="can_focus">False</property>
<property name="title" translatable="yes">SkiaSharp</property>
<property name="default_width">600</property>
<property name="default_height">600</property>
</object>
</interface>
這個檔案就是 GTK 的界面描述,更多關于這個檔案的知識,還請自行了解,這不是本文的重點,如果對 GtkSharp 不熟悉,不知道如何配置,推薦到本文最后獲取所有的代碼
編輯 MainWindow.cs 修改建構式為以下代碼,以下代碼的含義是將一個 SKDrawingArea 物件作為視窗顯示的內容,這里的 SKDrawingArea 物件里提供了 PaintSurface 事件,通過此事件即可獲取到 Skia 的畫布,在建構式里,對接了 GtkSharp 和 Skia 的邏輯
public MainWindow()
: this(new Builder("MainWindow.glade"))
{
}
private MainWindow(Builder builder)
: base(builder.GetObject("MainWindow").Handle)
{
builder.Autoconnect(this);
DeleteEvent += OnWindowDeleteEvent;
var skiaView = new SKDrawingArea();
skiaView.PaintSurface += OnPaintSurface;
skiaView.Show();
Child = skiaView;
}
private void OnWindowDeleteEvent(object sender, DeleteEventArgs a)
{
Application.Quit();
}
private void OnPaintSurface(object sender, SKPaintSurfaceEventArgs e)
{
// 忽略代碼
}
在 OnPaintSurface 方法里面就是 Skia 的渲染回呼,有點和 WPF 的 OnRender 方法類似,在此函式里,通過 e.Surface.Canvas 繪制的內容,將會輸出到 GtkSharp 的視窗
根據上文的 WPF 對接 Skia 和 MAUI 的邏輯,可以了解到對接的方式是使用 Skia 的畫布創建 MAUI 的 SkiaCanvas 畫布,如以下代碼
// the the canvas and properties
var canvas = e.Surface.Canvas;
var skiaCanvas = new SkiaCanvas()
{
Canvas = canvas,
};
盡管推薦 OnPaintSurface 方法只處理繪制邏輯,不要在這個方法里面寫業務邏輯,但為了方便理解,在本文的例子就在 OnPaintSurface 方法處理了 PPT 決議和圖表繪制邏輯,請不要在實際的專案上,在 PaintSurface 事件里,處理業務邏輯
決議 PPT 檔案需要先獲取到測驗檔案,再使用上文的 ModelReader 創建出 AreaChartRender 物件,這些邏輯在各個平臺都是相同的
var file = new FileInfo(System.IO.Path.Combine(System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "Test.pptx"));
var modelReader = new ModelReader();
AreaChartRender areaChartRender = modelReader.BuildAreaChartRender(file);
再使用和上文一樣的對接 Skia 和 MAUI 的邏輯進行對接,對接方法依然是獲取到 skiaCanvas 物件,傳入到 AreaChartRender 繪制,這就是最關鍵的代碼
areaChartRender.Render(skiaCanvas);
可以看到,關鍵的代碼也只需要一句即可完成
這就是在 GtkSharp 上對接的方法,核心的代碼如下
private void OnPaintSurface(object sender, SKPaintSurfaceEventArgs e)
{
var file = new FileInfo(System.IO.Path.Combine(System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "Test.pptx"));
var modelReader = new ModelReader();
AreaChartRender areaChartRender = modelReader.BuildAreaChartRender(file);
// the the canvas and properties
var canvas = e.Surface.Canvas;
// make sure the canvas is blank
canvas.Clear(SKColors.White);
var skiaCanvas = new SkiaCanvas()
{
Canvas = canvas,
};
areaChartRender.Render(skiaCanvas);
}
運行的效果如下

這就是使用 MAUI 在 Windows 和 Linux 上決議和繪制 PPT 的圖表的例子,本文忽略了很多細節,更多細節請閱讀本文使用的代碼
整個 MAUI 是一個非常龐大和強大的框架,如此龐大的框架想要完全完成還是需要一些時間的,本文所用到的僅僅只是 MAUI 的渲染層,我將 MAUI 的渲染層拆開,即可放入到現有的應用里面,也可以輸出到本地圖片檔案,既支持 Windows 平臺,又支持 Linux 平臺,可以使用默認自帶的 MAUI 具體平臺實作,也可以自己基于介面,自己實作一套渲染進行對接
代碼
本文以上的測驗檔案和代碼放在github 和 gitee 歡迎訪問
可以通過如下方式獲取本文的源代碼,先創建一個空檔案夾,接著使用命令列 cd 命令進入此空檔案夾,在命令列里面輸入以下代碼,即可獲取到本文的代碼
git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin c5477e93289a71c05787af4b1ab1dbb23f18b0e6
以上使用的是 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源
git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
獲取代碼之后,打開 Pptx.sln 檔案,里面的包含三個專案:
- PptxCore 是 PPT 決議和圖表繪制的專案,此專案可以在 Windows 和 Linux 平臺使用
- Pptx 是一個 WPF 專案
- PptxGtk 是一個 GtkSharp 專案
更多
更多關于 OpenXML 決議請看 Office 使用 OpenXML SDK 決議檔案博客目錄
更多關于 MAUI 請看 博客導航
博客園博客只做備份,博客發布就不再更新,如果想看最新博客,請到 https://blog.lindexi.com/

本作品采用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可,歡迎轉載、使用、重新發布,但務必保留文章署名[林德熙](http://blog.csdn.net/lindexi_gd)(包含鏈接:http://blog.csdn.net/lindexi_gd ),不得用于商業目的,基于本文修改后的作品務必以相同的許可發布,如有任何疑問,請與我[聯系](mailto:[email protected]),
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/505992.html
標籤:.NET技术
上一篇:劃分中小型企業網
下一篇:劃分中小型企業網
