Lors de l'exécution du dernier projet au travail, mon collègue et moi avons été confrontés au fait que certaines méthodes et constructeurs de System.Drawing tombent de OutOfMemory dans des endroits complètement ordinaires et lorsqu'il y a encore beaucoup de mémoire libre.
L'essence du problème
Par exemple, prenez ce code C #:
using System.Drawing; using System.Drawing.Drawing2D; namespace TempProject { static class Program { static void Main() { var point1 = new PointF(-3.367667E-16f, 0f); var point2 = new PointF(3.367667E-16f, 100f); var brush = new LinearGradientBrush(point1, point2, Color.White, Color.Black); } } }
Lorsque la dernière ligne est exécutée, une exception OutOfMemoryException est garantie d'être levée, quelle que soit la quantité de mémoire disponible. De plus, si vous remplacez 3.367667E-16f et -3.367667E-16f par 0, ce qui est très proche de la vérité, tout fonctionnera bien - le remplissage sera créé. À mon avis, ce comportement semble étrange. Voyons pourquoi cela se produit et comment y faire face.
Découvrez les causes de la maladie
Pour commencer, nous apprenons ce qui se passe dans le constructeur LinearGradientBrush. Pour ce faire, vous pouvez consulter
referencesource.microsoft.com . Il y aura les éléments suivants:
public LinearGradientBrush(PointF point1, PointF point2, Color color1, Color color2) { IntPtr brush = IntPtr.Zero; int status = SafeNativeMethods.Gdip.GdipCreateLineBrush( new GPPOINTF(point1), new GPPOINTF(point2), color1.ToArgb(), color2.ToArgb(), (int)WrapMode.Tile, out brush ); if (status != SafeNativeMethods.Gdip.Ok) throw SafeNativeMethods.Gdip.StatusException(status); SetNativeBrushInternal(brush); }
Il est facile de remarquer que la chose la plus importante ici est l'appel GDI + de la méthode GdipCreateLineBrush. Donc, vous devez regarder ce qui se passe à l'intérieur. Pour ce faire, utilisez IDA + HexRays. Téléchargez dans IDA gdiplus.dll. Si vous devez déterminer la version de la bibliothèque à déboguer, vous pouvez utiliser l'Explorateur de processus de SysInternals. En outre, vous pouvez rencontrer des problèmes avec les autorisations sur le dossier où se trouve gdiplus.dll. Ils sont résolus en changeant le propriétaire de ce dossier.
Ouvrez donc gdiplus.dll dans l'IDA. Attendons que le fichier soit traité. Après cela, sélectionnez dans le menu: Affichage → Ouvrir les sous-vues → Exportations pour ouvrir toutes les fonctions qui sont exportées à partir de cette bibliothèque, et recherchez GdipCreateLineBrush là-bas.
Grâce au chargement des caractères, à la puissance des HexRays et à la
documentation , vous pouvez facilement traduire le code de méthode de l'assembleur en code C ++ lisible:
GdipCreateLineBrush GpStatus __userpurge GdipCreateLineBrush@<eax>(int a1@<edi>, GpPointF *point1, GpPointF *point2, int color1, int color2, int wrapMode, GpRectGradient **result) { GpStatus status;
Le code de cette méthode est complètement clair. Son essence réside dans les lignes:
if ( result && point1 && point2 && wrapMode != 4 ) { vColor1 = color1; vColor2 = color2; v8 = operator new(a1); status = 0; if ( v8 ) v9 = GpLineGradient::GpLineGradient(v8, point1, point2, &vColor1, &vColor2, wrapMode); else v9 = 0; *result = v9; if ( !CheckValid<GpHatch>(result) ) status = OutOfMemory } else { status = InvalidParameter; }
GdiPlus vérifie si les paramètres d'entrée sont corrects, et si ce n'est pas le cas, renvoie un paramètre InvalidParameter. Sinon, un GpLineGradient est créé et vérifié pour sa validité. Si la validation échoue, OutOfMemory est renvoyé. Apparemment, c'est notre cas, et, par conséquent, nous devons comprendre ce qui se passe à l'intérieur du constructeur GpLineGradient:
GpLineGradient :: GpLineGradient GpRectGradient *__thiscall GpLineGradient::GpLineGradient(GpGradientBrush *this, GpPointF *point1, GpPointF *point2, int color1, int color2, int wrapMode) { GpGradientBrush *v6;
Ici, les variables sont initialisées, qui sont ensuite remplies dans LinearGradientRectFromPoints et SetLineGradient. J'ose supposer que rect est un rectangle de remplissage basé sur point1 et point2, pour voir cela, vous pouvez regarder LinearGradientRectFromPoints:
LinearGradientRectFromPoints GpStatus __fastcall LinearGradientRectFromPoints(GpPointF *p1, GpPointF *p2, GpRectF *result) { double vP1X;
Comme prévu, rect est un rectangle de points point1 et point2.
Revenons maintenant à notre problème principal et voyons ce qui se passe à l'intérieur de SetLineGradient:
SetLinegradient GpStatus __thiscall GpLineGradient::SetLineGradient(DpGradientBrush *this, GpPointF *p1, GpPointF *p2, GpRectF *rect, int color1, int color2, float angle, int zero, int wrapMode) { _DWORD *v10;
Dans SetLineGradient également, seule l'initialisation du champ se produit. Donc, nous devons aller plus loin:
int __fastcall CalcLinearGradientXform(int zero, GpRectF *rect, float angle, int a4) {
Et enfin:
GpStatus __thiscall GpMatrix::InferAffineMatrix(int this, GpPointF *points, GpRectF *rect) {
La méthode InferAffineMatrix est exactement ce qui nous intéresse. Ici, la zone du rect est vérifiée - le rectangle de points d'origine, et s'il est inférieur à 0,0000000011920929, alors InferAffineMatrix renvoie InvalidParameter. 0.00000011920929 est la
machine epsilon pour float (FLT_EPSILON). Vous pouvez voir à quel point Microsoft considère la zone du rectangle intéressante:
rectArea = bottom * right - x * y - (y * width + x * height);
De la zone en bas à droite, soustrayez la zone en haut à gauche, puis soustrayez la zone au-dessus du rectangle et à gauche du rectangle. Pourquoi est-ce fait, je ne comprends pas; J'espère qu'un jour je connais cette méthode secrète.
Donc ce que nous avons:
- InnerAffineMatrix renvoie InvalidParameter;
- CalcLinearGradientXForm lève ce résultat plus haut;
- Dans SetLineGradient, l'exécution suivra la branche if et la méthode renverra également InvalidParameter;
- Le constructeur GpLineGradient perdra des informations sur le paramètre InvalidParameter et renverra un objet GpLineGradient qui n'est pas initialisé à la fin - c'est très mauvais!
- GdipCreateLineBrush vérifiera dans CheckValid (ligne 26) que l'objet GpLineGradient est valide avec des champs qui ne sont pas complètement remplis et renverra naturellement faux.
- Après cela, le statut passera à OutOfMemory, qui obtiendra .NET à la sortie de la méthode GDI +.
Il s'avère que Microsoft, pour une raison quelconque, ignore le statut de retour de certaines méthodes, fait des hypothèses incorrectes à cause de cela et complique la compréhension du travail de la bibliothèque pour d'autres programmeurs. Mais tout ce que vous aviez à faire était de lever le statut plus haut à partir du constructeur GpLineGradient, et de vérifier la valeur de retour dans OK dans GdipCreateLineBrush et de renvoyer le statut du constructeur. Ensuite, pour les utilisateurs de GDI +, le message d'erreur qui s'est produit à l'intérieur de la bibliothèque serait plus logique.
Option avec remplacement des très petits nombres par zéro, c'est-à-dire avec remplissage vertical, il s'exécute sans erreur en raison de la magie que Microsoft fait dans la méthode LinearGradientRectFromPoints dans les lignes 35 à 45:
La magie if ( IsCloseReal(p1->X, p2->X) ) { result->X = vLeft - 0.5 * vHeight; result->Width = vHeight; vWidth = vHeight; } if ( IsCloseReal(p1->Y, p2->Y) ) { result->Y = vTop - vWidth * 0.5; result->Height = vWidth; }
Comment traiter?
Comment éviter ce plantage dans le code .NET? L'option la plus simple et la plus évidente consiste à comparer la zone du rectangle à partir des points 1 et 2 avec FLT_EPSILON et à ne pas créer de dégradé si la zone est plus petite. Mais avec cette option, nous perdrons des informations sur le dégradé et une zone non peinte sera dessinée, ce qui n'est pas bon. Je vois une option plus acceptable lors de la vérification de l'angle du remplissage dégradé, et s'il s'avère que le remplissage est proche de l'horizontale ou de la verticale, définissez les mêmes paramètres pour les points:
Ma solution en C # static LinearGradientBrush CreateBrushSafely(PointF p1, PointF p2) { if(IsShouldNormalizePoints(p1, p2)) { if(!NormalizePoints(ref p1, ref p2)) return null; } var brush = new LinearGradientBrush(p1, p2, Color.White, Color.Black); return brush; } static bool IsShouldNormalizePoints(PointF p1, PointF p2) { float width = Math.Abs(p1.X - p2.X); float height = Math.Abs(p1.Y - p2.Y); return width * height < FLT_EPSILON && !(IsCloseFloat(p1.X, p2.X) || IsCloseFloat(p1.Y, p2.Y)); } static bool IsCloseFloat(float v1, float v2) { var t = v2 == 0.0f ? 1.0f : v2; return Math.Abs((v1 - v2) / t) < FLT_EPSILON; } static bool NormalizePoints(ref PointF p1, ref PointF p2) { const double twoDegrees = 0.03490658503988659153847381536977d; float width = Math.Abs(p1.X - p2.X); float height = Math.Abs(p1.Y - p2.Y); var angle = Math.Atan2(height, width); if (Math.Abs(angle) < twoDegrees) { p1.Y = p2.Y; return true; } if (Math.Abs(angle - Math.PI / 2) < twoDegrees) { p1.X = p2.X; return true; } return false; }
Comment vont les concurrents?
Voyons ce qui se passe dans Wine. Pour ce faire, regardez le
code source de Wine , ligne 306:
GdipCreateLineBrush of Wine GpStatus WINGDIPAPI GdipCreateLineBrush(GDIPCONST GpPointF* startpoint, GDIPCONST GpPointF* endpoint, ARGB startcolor, ARGB endcolor, GpWrapMode wrap, GpLineGradient **line) { TRACE("(%s, %s, %x, %x, %d, %p)\n", debugstr_pointf(startpoint), debugstr_pointf(endpoint), startcolor, endcolor, wrap, line); if(!line || !startpoint || !endpoint || wrap == WrapModeClamp) return InvalidParameter; if (startpoint->X == endpoint->X && startpoint->Y == endpoint->Y) return OutOfMemory; *line = heap_alloc_zero(sizeof(GpLineGradient)); if(!*line) return OutOfMemory; (*line)->brush.bt = BrushTypeLinearGradient; (*line)->startpoint.X = startpoint->X; (*line)->startpoint.Y = startpoint->Y; (*line)->endpoint.X = endpoint->X; (*line)->endpoint.Y = endpoint->Y; (*line)->startcolor = startcolor; (*line)->endcolor = endcolor; (*line)->wrap = wrap; (*line)->gamma = FALSE; (*line)->rect.X = (startpoint->X < endpoint->X ? startpoint->X: endpoint->X); (*line)->rect.Y = (startpoint->Y < endpoint->Y ? startpoint->Y: endpoint->Y); (*line)->rect.Width = fabs(startpoint->X - endpoint->X); (*line)->rect.Height = fabs(startpoint->Y - endpoint->Y); if ((*line)->rect.Width == 0) { (*line)->rect.X -= (*line)->rect.Height / 2.0f; (*line)->rect.Width = (*line)->rect.Height; } else if ((*line)->rect.Height == 0) { (*line)->rect.Y -= (*line)->rect.Width / 2.0f; (*line)->rect.Height = (*line)->rect.Width; } (*line)->blendcount = 1; (*line)->blendfac = heap_alloc_zero(sizeof(REAL)); (*line)->blendpos = heap_alloc_zero(sizeof(REAL)); if (!(*line)->blendfac || !(*line)->blendpos) { heap_free((*line)->blendfac); heap_free((*line)->blendpos); heap_free(*line); *line = NULL; return OutOfMemory; } (*line)->blendfac[0] = 1.0f; (*line)->blendpos[0] = 1.0f; (*line)->pblendcolor = NULL; (*line)->pblendpos = NULL; (*line)->pblendcount = 0; linegradient_init_transform(*line); TRACE("<-- %p\n", *line); return Ok; }
Voici la seule validation des paramètres:
if(!line || !startpoint || !endpoint || wrap == WrapModeClamp) return InvalidParameter;
Très probablement, ce qui suit a été écrit pour la compatibilité avec Windows:
if (startpoint->X == endpoint->X && startpoint->Y == endpoint->Y) return OutOfMemory;
Et le reste n'a rien d'intéressant - l'allocation de mémoire et le remplissage des champs. D'après le code source, il devient évident que dans Wine, la création d'un remplissage dégradé problématique doit être effectuée sans erreur. Et vraiment - si vous exécutez le programme suivant sur Windows (j'ai couru sur Windows10x64)
Programme de test #include <Windows.h> #include "stdafx.h" #include <gdiplus.h> #include <iostream> #pragma comment(lib,"gdiplus.lib") void CreateBrush(float x1, float x2) { Gdiplus::LinearGradientBrush linGrBrush( Gdiplus::PointF(x1, -0.5f), Gdiplus::PointF(x2, 10.5f), Gdiplus::Color(255, 0, 0, 0), Gdiplus::Color(255, 255, 255, 255)); const int status = linGrBrush.GetLastStatus(); const char* result; if (status == 3) { result = "OutOfMemory"; } else { result = "Ok"; } std::cout << result << "\n"; } int main() { Gdiplus::GdiplusStartupInput gdiplusStartupInput; ULONG_PTR gdiplusToken; Gdiplus::GdiplusStartup(&gdiplusToken, &gdiplusStartupInput, NULL); Gdiplus::Graphics myGraphics(GetDC(0)); CreateBrush(-3.367667E-16f, 3.367667E-16f); CreateBrush(0, 0); return 0; }
Cela dans la console Windows sera:
Outofmemory
Ok
et dans Ubuntu avec Wine:
Ok
Ok
Il s'avère que soit je fais quelque chose de mal, soit Wine dans cette affaire fonctionne plus logiquement que Windows.
Conclusion
J'espère vraiment que je n'ai pas compris quelque chose et le comportement de GDI + est logique. Certes, il n'est pas du tout clair pourquoi Microsoft a fait exactement cela. J'ai fouillé beaucoup dans leurs autres produits, et il y a aussi des choses qui, dans une société décente, n'auraient pas passé le Code Review.