Al ejecutar el último proyecto en el trabajo, mi colega y yo nos enfrentamos al hecho de que algunos métodos y constructores en System.Drawing caen de OutOfMemory en lugares completamente normales, y cuando todavía hay mucha memoria libre.
La esencia del problema
Por ejemplo, tome este código 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); } } }
Cuando se ejecuta la última línea, se garantiza que se lanzará una excepción OutOfMemoryException, independientemente de la cantidad de memoria disponible. Además, si reemplaza 3.367667E-16f y -3.367667E-16f con 0, que está muy cerca de la verdad, todo funcionará bien: se creará el relleno. En mi opinión, este comportamiento parece extraño. Veamos por qué sucede esto y cómo lidiar con eso.
Descubre las causas de la enfermedad.
Para empezar, aprendemos lo que sucede en el constructor LinearGradientBrush. Para hacer esto, puede mirar
referencesource.microsoft.com . Habrá lo siguiente:
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); }
Es fácil notar que lo más importante aquí es la llamada GDI + del método GdipCreateLineBrush. Por lo tanto, debe observar lo que sucede dentro de él. Para hacer esto, use IDA + HexRays. Descargar a IDA gdiplus.dll. Si necesita determinar qué versión de la biblioteca debe depurar, puede usar el Explorador de procesos de SysInternals. Además, puede experimentar problemas con los permisos en la carpeta donde se encuentra gdiplus.dll. Se resuelven cambiando el propietario de esta carpeta.
Entonces, abra gdiplus.dll en la IDA. Esperemos a que se procese el archivo. Después de eso, seleccione en el menú: Ver → Abrir subvistas → Exportaciones para abrir todas las funciones que se exportan desde esta biblioteca, y encuentre GdipCreateLineBrush allí.
Gracias a la carga de caracteres, el poder de los HexRays y la
documentación , puede traducir fácilmente el código del método del ensamblador en código legible de C ++:
GdipCreateLineBrush GpStatus __userpurge GdipCreateLineBrush@<eax>(int a1@<edi>, GpPointF *point1, GpPointF *point2, int color1, int color2, int wrapMode, GpRectGradient **result) { GpStatus status;
El código para este método es completamente claro. Su esencia radica en las líneas:
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 comprueba si los parámetros de entrada son correctos y, si no lo es, devuelve un parámetro no válido. De lo contrario, se crea un GpLineGradient y se verifica su validez. Si la validación falla, se devuelve OutOfMemory. Aparentemente, este es nuestro caso y, por lo tanto, tenemos que descubrir qué sucede dentro del constructor GpLineGradient:
GpLineGradient :: GpLineGradient GpRectGradient *__thiscall GpLineGradient::GpLineGradient(GpGradientBrush *this, GpPointF *point1, GpPointF *point2, int color1, int color2, int wrapMode) { GpGradientBrush *v6;
Aquí se inicializan las variables, que luego se rellenan en LinearGradientRectFromPoints y SetLineGradient. Me atrevo a suponer que rect es un rectángulo de relleno basado en el punto 1 y el punto 2, para ver esto, puede mirar LinearGradientRectFromPoints:
LinearGradientRectFromPoints GpStatus __fastcall LinearGradientRectFromPoints(GpPointF *p1, GpPointF *p2, GpRectF *result) { double vP1X;
Como se esperaba, rect es un rectángulo de puntos point1 y point2.
Ahora volvamos a nuestro problema principal y veamos qué sucede dentro 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;
En SetLineGradient, también, solo se produce la inicialización del campo. Entonces, necesitamos ir más profundo:
int __fastcall CalcLinearGradientXform(int zero, GpRectF *rect, float angle, int a4) {
Y finalmente:
GpStatus __thiscall GpMatrix::InferAffineMatrix(int this, GpPointF *points, GpRectF *rect) {
El método InferAffineMatrix es exactamente lo que nos interesa. Aquí se verifica el área del rectángulo: el rectángulo original de puntos, y si es menor que 0.00000011920929, InferAffineMatrix devuelve InvalidParameter. 0.00000011920929 es la
máquina épsilon para flotador (FLT_EPSILON). Puedes ver lo interesante que Microsoft considera el área del rectángulo:
rectArea = bottom * right - x * y - (y * width + x * height);
Desde el área inferior derecha, reste el área superior izquierda, luego reste el área arriba del rectángulo y a la izquierda del rectángulo. Por qué se hace esto, no entiendo; Espero algún día conocer este método secreto.
Entonces lo que tenemos:
- InnerAffineMatrix devuelve InvalidParameter;
- CalcLinearGradientXForm arroja este resultado más alto;
- En SetLineGradient, la ejecución seguirá la rama if, y el método también devolverá InvalidParameter;
- El constructor GpLineGradient perderá información sobre el parámetro InvalidParameter y devolverá un objeto GpLineGradient que no está inicializado hasta el final. ¡Esto es muy malo!
- GdipCreateLineBrush verificará en CheckValid (línea 26) que el objeto GpLineGradient es válido con campos que no están completamente llenos y, naturalmente, devolverá falso.
- Después de eso, el estado cambiará a OutOfMemory, que obtendrá .NET a la salida del método GDI +.
Resulta que Microsoft por alguna razón ignora el estado de retorno de algunos métodos, hace suposiciones incorrectas debido a esto y complica la comprensión del trabajo de la biblioteca para otros programadores. Pero todo lo que tenía que hacer era aumentar el estado del constructor GpLineGradient y verificar el valor de retorno en OK en GdipCreateLineBrush y, de lo contrario, devolver el estado del constructor. Entonces, para los usuarios de GDI +, el mensaje de error que ocurrió dentro de la biblioteca se vería más lógico.
Opción para reemplazar números muy pequeños con cero, es decir con relleno vertical, se ejecuta sin errores debido a la magia que Microsoft hace en el método LinearGradientRectFromPoints en las líneas 35 a 45:
Magia 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; }
¿Cómo tratarlo?
¿Cómo evitar este bloqueo en el código .NET? La opción más simple y obvia es comparar el área del rectángulo desde los puntos 1 y el punto 2 con FLT_EPSILON y no crear un gradiente si el área es más pequeña. Pero con esta opción, perderemos información sobre el gradiente y se dibujará un área sin pintar, lo que no es bueno. Veo una opción más aceptable cuando verifico el ángulo del relleno de degradado, y si resulta que el relleno está cerca de horizontal o vertical, establezca los parámetros correspondientes para los puntos de la misma manera:
Mi solución 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; }
¿Cómo están los competidores?
Veamos qué está pasando en Wine. Para hacer esto, mire el
código fuente de Wine , línea 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; }
Aquí está la única validación de los parámetros:
if(!line || !startpoint || !endpoint || wrap == WrapModeClamp) return InvalidParameter;
Lo más probable es que lo siguiente se haya escrito para compatibilidad con Windows:
if (startpoint->X == endpoint->X && startpoint->Y == endpoint->Y) return OutOfMemory;
Y el resto no es nada interesante: asignación de memoria y completar los campos. A partir del código fuente, se hace evidente que en Wine la creación de un relleno de gradiente problemático debe realizarse sin errores. Y realmente, si ejecuta el siguiente programa en Windows (ejecuté en Windows 10x64)
Programa de prueba #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; }
Eso en la consola de Windows será:
Fuera de memoria
Ok
y en Ubuntu con Wine:
Ok
Ok
Resulta que o estoy haciendo algo mal, o Wine en este asunto funciona más lógicamente que Windows.
Conclusión
Realmente espero no haber entendido algo y el comportamiento de GDI + es lógico. Es cierto que no está nada claro por qué Microsoft hizo exactamente eso. Profundicé mucho en sus otros productos, y también hay cosas que en una sociedad decente no habrían aprobado la Revisión del Código.